mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0b14c5fd | ||
|
|
056561de41 | ||
|
|
f2215df72e | ||
|
|
72f8957163 | ||
|
|
73f0c15790 | ||
|
|
0558693949 | ||
|
|
2a9cb02cc9 |
@@ -79,7 +79,7 @@ jobs:
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
Generated
+29
-29
@@ -120,9 +120,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -451,9 +451,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
|
||||
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -801,9 +801,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
@@ -1384,9 +1384,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1399,9 +1399,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1409,15 +1409,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -1437,32 +1437,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
@@ -1472,9 +1472,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -3633,18 +3633,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -946,7 +946,28 @@ impl fmt::Debug for LoadedCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新成功后的完整 CAS 写回。
|
||||
/// 与 credential revision CAS 同事务提交的账号错误事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialStateWrite {
|
||||
pub credential_state: CredentialState,
|
||||
pub observed_at: SystemTime,
|
||||
pub error_reason: Option<AccountErrorReason>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// [`CredentialCasUpdate`] 跨 store 边界的命名字段。
|
||||
pub struct CredentialCasUpdateParts {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub profile: ProviderAccountUpdate,
|
||||
pub credential: PlaintextCredential,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<SystemTime>,
|
||||
pub next_refresh_at: Option<SystemTime>,
|
||||
pub account_state: Option<Box<CredentialStateWrite>>,
|
||||
}
|
||||
|
||||
/// 刷新后的完整 CAS 写回。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct CredentialCasUpdate {
|
||||
account_id: ProviderAccountId,
|
||||
@@ -956,6 +977,7 @@ pub struct CredentialCasUpdate {
|
||||
has_refresh_token: bool,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
next_refresh_at: Option<SystemTime>,
|
||||
account_state: Option<Box<CredentialStateWrite>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CredentialCasUpdate {
|
||||
@@ -969,6 +991,7 @@ impl fmt::Debug for CredentialCasUpdate {
|
||||
.field("has_refresh_token", &self.has_refresh_token)
|
||||
.field("access_token_expires_at", &self.access_token_expires_at)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.field("account_state", &self.account_state)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1002,9 +1025,34 @@ impl CredentialCasUpdate {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
account_state: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将刷新调度与账号错误事实放入同一个 revision CAS。
|
||||
#[must_use]
|
||||
pub fn with_account_state(
|
||||
mut self,
|
||||
credential_state: CredentialState,
|
||||
observed_at: SystemTime,
|
||||
error_reason: Option<AccountErrorReason>,
|
||||
message: Option<String>,
|
||||
) -> Self {
|
||||
let message = message.filter(|value| !value.trim().is_empty());
|
||||
let error_reason = if credential_state == CredentialState::Ready {
|
||||
message.as_ref().and(error_reason)
|
||||
} else {
|
||||
error_reason.or_else(|| credential_state.error_reason())
|
||||
};
|
||||
self.account_state = Some(Box::new(CredentialStateWrite {
|
||||
credential_state,
|
||||
observed_at,
|
||||
error_reason,
|
||||
message,
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn account_id(&self) -> &ProviderAccountId {
|
||||
&self.account_id
|
||||
@@ -1041,26 +1089,17 @@ impl CredentialCasUpdate {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_parts(
|
||||
self,
|
||||
) -> (
|
||||
ProviderAccountId,
|
||||
CredentialRevision,
|
||||
ProviderAccountUpdate,
|
||||
PlaintextCredential,
|
||||
bool,
|
||||
Option<SystemTime>,
|
||||
Option<SystemTime>,
|
||||
) {
|
||||
(
|
||||
self.account_id,
|
||||
self.expected_revision,
|
||||
self.profile,
|
||||
self.credential,
|
||||
self.has_refresh_token,
|
||||
self.access_token_expires_at,
|
||||
self.next_refresh_at,
|
||||
)
|
||||
pub fn into_parts(self) -> CredentialCasUpdateParts {
|
||||
CredentialCasUpdateParts {
|
||||
account_id: self.account_id,
|
||||
expected_revision: self.expected_revision,
|
||||
profile: self.profile,
|
||||
credential: self.credential,
|
||||
has_refresh_token: self.has_refresh_token,
|
||||
access_token_expires_at: self.access_token_expires_at,
|
||||
next_refresh_at: self.next_refresh_at,
|
||||
account_state: self.account_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1141,14 @@ pub enum QuotaWriteOutcome {
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// 只推进 Provider quota 文档的最后成功查询时间。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QuotaObservationTouch {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub observed_at: SystemTime,
|
||||
}
|
||||
|
||||
/// 不改 Provider 原始 quota JSON 的额度访问事实写入。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QuotaAccessChange {
|
||||
@@ -1117,10 +1164,13 @@ pub struct AccountStateChange {
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub credential_state: CredentialState,
|
||||
pub observed_at: SystemTime,
|
||||
/// 受控错误原因;恢复 Ready 时必须清空。
|
||||
/// 受控错误原因;无失败事实的 Ready 写入必须清空。
|
||||
///
|
||||
/// Ready 凭据可以在 AT 过期后继续 RT 恢复,此时允许保留最近一次
|
||||
/// 刷新失败原因,供错误状态投影展示。
|
||||
pub error_reason: Option<AccountErrorReason>,
|
||||
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
|
||||
/// 恢复 Ready 时必须清空。
|
||||
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1171,6 +1221,11 @@ pub trait ProviderAccountStore: Send + Sync {
|
||||
observation: QuotaObservation,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
|
||||
@@ -103,7 +103,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, CoreStoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -111,7 +111,8 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
if profile.account_id != account_id {
|
||||
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
|
||||
}
|
||||
@@ -122,13 +123,38 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
CREDENTIALS_MAX_BYTES,
|
||||
)
|
||||
.map_err(core_store_error)?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map(|state| state.credential_state.as_str());
|
||||
let credential_observed_at = account_state
|
||||
.as_ref()
|
||||
.map(|state| DateTime::<Utc>::from(state.observed_at));
|
||||
let error_reason = account_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.error_reason.map(AccountErrorReason::as_str));
|
||||
let message = account_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.message.as_deref());
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update provider_accounts
|
||||
set name = $3, email = $4, plan_type = $5,
|
||||
provider_credentials_json = $6,
|
||||
credential_revision = credential_revision + 1,
|
||||
has_refresh_token = $7, access_token_expires_at = $8,
|
||||
next_refresh_at = $9, updated_at = now()
|
||||
next_refresh_at = $9,
|
||||
credential_state = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $10 else credential_state end,
|
||||
credential_observed_at = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $11 else credential_observed_at end,
|
||||
last_error_reason = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $12 else last_error_reason end,
|
||||
last_error_message = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $13 else last_error_message end,
|
||||
updated_at = greatest(now(), coalesce($11, now()))
|
||||
where id = $1 and credential_revision = $2
|
||||
returning credential_revision",
|
||||
)
|
||||
@@ -141,6 +167,10 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
.bind(has_refresh_token)
|
||||
.bind(access_token_expires_at.map(DateTime::<Utc>::from))
|
||||
.bind(next_refresh_at.map(DateTime::<Utc>::from))
|
||||
.bind(credential_state)
|
||||
.bind(credential_observed_at)
|
||||
.bind(error_reason)
|
||||
.bind(message)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
|
||||
@@ -280,6 +310,25 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, CoreStoreError> {
|
||||
let updated = self
|
||||
.touch_provider_quota_observation(
|
||||
touch.account_id.as_str(),
|
||||
Revision::new(touch.expected_revision.get()).map_err(core_store_error)?,
|
||||
DateTime::<Utc>::from(touch.observed_at),
|
||||
)
|
||||
.await
|
||||
.map_err(core_store_error)?;
|
||||
Ok(if updated {
|
||||
QuotaWriteOutcome::Updated
|
||||
} else {
|
||||
QuotaWriteOutcome::Conflict
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), CoreStoreError> {
|
||||
let updated = self
|
||||
.apply_provider_account_state(ProviderAccountStateUpdate {
|
||||
|
||||
@@ -38,12 +38,13 @@ use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision as CoreCredentialRevision,
|
||||
CredentialState, LoadedCredential, NewProviderAccount as CoreNewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount as CoreProviderAccount,
|
||||
ProviderAccountId as CoreProviderAccountId, ProviderAccountIdentity, ProviderAccountStore,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
|
||||
CredentialRevision as CoreCredentialRevision, CredentialState, LoadedCredential,
|
||||
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
|
||||
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
|
||||
ProviderAccountIdentity, ProviderAccountStore,
|
||||
ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaAccessChange, QuotaAccessState,
|
||||
QuotaEvidence, QuotaObservation, QuotaState, QuotaWriteOutcome,
|
||||
QuotaEvidence, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
|
||||
use gateway_core::routing::{AccountGroupId, ProviderKind};
|
||||
|
||||
@@ -29,6 +29,12 @@ pub trait ProviderAccountRepository: Send + Sync {
|
||||
observed_at: DateTime<Utc>,
|
||||
state: QuotaState,
|
||||
) -> StoreResult<bool>;
|
||||
async fn touch_provider_quota_observation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected_revision: Revision,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> StoreResult<bool>;
|
||||
async fn apply_provider_quota_access(
|
||||
&self,
|
||||
account_id: &str,
|
||||
@@ -237,13 +243,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
else credential_observed_at
|
||||
end,
|
||||
last_error_reason = case
|
||||
when enabled and upstream_user_id is not null then
|
||||
case when $3 = 'ready' then null else $5 end
|
||||
when enabled and upstream_user_id is not null then $5
|
||||
else last_error_reason
|
||||
end,
|
||||
last_error_message = case
|
||||
when enabled and upstream_user_id is not null then
|
||||
case when $3 = 'ready' then null else $6 end
|
||||
when enabled and upstream_user_id is not null then $6
|
||||
else last_error_message
|
||||
end,
|
||||
updated_at = case when enabled then greatest(now(), $4) else updated_at end
|
||||
@@ -354,6 +358,29 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn touch_provider_quota_observation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected_revision: Revision,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> StoreResult<bool> {
|
||||
require_nonempty(ENTITY, "account_id", account_id)?;
|
||||
let result = sqlx::query(
|
||||
"update provider_accounts
|
||||
set quota_observed_at = $3
|
||||
where id = $1 and credential_revision = $2
|
||||
and provider_quota_json is not null
|
||||
and (quota_observed_at is null or quota_observed_at <= $3)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(to_i64(expected_revision.get())?)
|
||||
.bind(observed_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("touch provider quota observation"))?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool> {
|
||||
require_nonempty(ENTITY, "id", id)?;
|
||||
let result = sqlx::query("delete from provider_accounts where id = $1 and not enabled")
|
||||
|
||||
@@ -18,8 +18,8 @@ use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
|
||||
CredentialRevision, CredentialState, OpaqueProviderData, PlaintextCredential,
|
||||
ProviderAccountId, ProviderAccountIdentity, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
|
||||
QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::routing::{AccountGroupId, ProviderKind};
|
||||
use gateway_store::{
|
||||
@@ -51,6 +51,22 @@ struct RecoveredAccountRow {
|
||||
access_token_expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CoreRefreshRow {
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
provider_credentials_json: serde_json::Value,
|
||||
credential_revision: i64,
|
||||
credential_state: String,
|
||||
last_error_reason: Option<String>,
|
||||
last_error_message: Option<String>,
|
||||
quota_access_state: String,
|
||||
quota_evidence: Option<String>,
|
||||
quota_access_observed_at: Option<chrono::DateTime<Utc>>,
|
||||
quota_reset_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_provider_account_adapter_implements_core_port() {
|
||||
fn assert_port<T: ProviderAccountStore>() {}
|
||||
@@ -156,6 +172,111 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_observation_touch_only_advances_the_refresh_time() {
|
||||
let Some(database) = TestDatabase::create("provider_account_quota_touch").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let account_id = ProviderAccountId::new("acct_quota_touch").expect("account id");
|
||||
repository
|
||||
.insert_provider_account(account(account_id.as_str(), "user-quota-touch"))
|
||||
.await
|
||||
.expect("insert quota fixture");
|
||||
let revision = CredentialRevision::new(1).expect("revision");
|
||||
let observed_at = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.expect("old observation time");
|
||||
let touched_at = observed_at
|
||||
.checked_add(Duration::from_secs(30))
|
||||
.expect("new observation time");
|
||||
let reset_at = observed_at
|
||||
.checked_add(Duration::from_secs(3_600))
|
||||
.expect("quota reset time");
|
||||
let quota_json = json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
quota: OpaqueProviderData::new(
|
||||
quota_json.as_object().expect("quota object").clone(),
|
||||
),
|
||||
observed_at,
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
observed_at,
|
||||
Some(reset_at),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
let account_updated_at: chrono::DateTime<Utc> = sqlx::query_scalar(
|
||||
"select updated_at from provider_accounts where id = 'acct_quota_touch'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load account update time before quota touch");
|
||||
|
||||
assert_eq!(
|
||||
repository
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id,
|
||||
expected_revision: revision,
|
||||
observed_at: touched_at,
|
||||
})
|
||||
.await
|
||||
.expect("touch quota refresh time"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
|
||||
let stored = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
serde_json::Value,
|
||||
chrono::DateTime<Utc>,
|
||||
String,
|
||||
Option<String>,
|
||||
chrono::DateTime<Utc>,
|
||||
Option<chrono::DateTime<Utc>>,
|
||||
chrono::DateTime<Utc>,
|
||||
),
|
||||
>(
|
||||
"select provider_quota_json, quota_observed_at, quota_access_state,
|
||||
quota_evidence, quota_access_observed_at, quota_reset_at, updated_at
|
||||
from provider_accounts where id = 'acct_quota_touch'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load touched quota row");
|
||||
assert_eq!(stored.0, quota_json);
|
||||
assert_eq!(
|
||||
stored.1.timestamp_micros(),
|
||||
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
|
||||
);
|
||||
assert_eq!(stored.2, "exhausted");
|
||||
assert_eq!(stored.3.as_deref(), Some("usage_limit_reached"));
|
||||
assert_eq!(
|
||||
stored.4.timestamp_micros(),
|
||||
chrono::DateTime::<Utc>::from(observed_at).timestamp_micros()
|
||||
);
|
||||
assert_eq!(
|
||||
stored.5.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(reset_at).timestamp_micros())
|
||||
);
|
||||
assert_eq!(stored.6, account_updated_at);
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
|
||||
let Some(database) = TestDatabase::create("provider_account_observation_fence").await else {
|
||||
@@ -280,6 +401,27 @@ async fn credential_error_message_is_persisted_and_cleared_on_recovery() {
|
||||
Some("upstream returned 402 payment required")
|
||||
);
|
||||
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: Some("Invalid refresh token.".to_owned()),
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
credential_state: CredentialState::Ready,
|
||||
observed_at: SystemTime::now(),
|
||||
error_reason: Some(AccountErrorReason::AccessTokenExpired),
|
||||
})
|
||||
.await
|
||||
.expect("persist recoverable refresh failure");
|
||||
let recoverable: (Option<String>, Option<String>) = sqlx::query_as(
|
||||
"select last_error_reason, last_error_message from provider_accounts where id = $1",
|
||||
)
|
||||
.bind(account_id.as_str())
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("read recoverable refresh failure");
|
||||
assert_eq!(recoverable.0.as_deref(), Some("access_token_expired"));
|
||||
assert_eq!(recoverable.1.as_deref(), Some("Invalid refresh token."));
|
||||
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
@@ -1208,8 +1350,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
})
|
||||
.await
|
||||
.expect("seed provider account");
|
||||
sqlx::query(
|
||||
"update provider_accounts
|
||||
set last_error_reason = 'access_token_expired',
|
||||
last_error_message = 'stale refresh failure'
|
||||
where id = 'acct_core_refresh'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed refresh failure details");
|
||||
|
||||
let account_id = ProviderAccountId::new("acct_core_refresh").expect("account ID");
|
||||
let quota_observed_at = SystemTime::now();
|
||||
let quota_reset_at = quota_observed_at
|
||||
.checked_add(Duration::from_secs(3_600))
|
||||
.expect("quota reset time");
|
||||
assert_eq!(
|
||||
repository
|
||||
.apply_quota_access(QuotaAccessChange {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: CredentialRevision::new(1).expect("credential revision"),
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
quota_observed_at,
|
||||
Some(quota_reset_at),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("seed quota exhaustion"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
let state_observed_at = SystemTime::now();
|
||||
let refreshed = CredentialCasUpdate::new(
|
||||
account_id.clone(),
|
||||
CredentialRevision::new(1).expect("credential revision"),
|
||||
@@ -1224,7 +1395,8 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
Some(SystemTime::now() + Duration::from_secs(3_600)),
|
||||
Some(SystemTime::now() + Duration::from_secs(1_800)),
|
||||
)
|
||||
.expect("valid refresh update");
|
||||
.expect("valid refresh update")
|
||||
.with_account_state(CredentialState::Ready, state_observed_at, None, None);
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_credential(refreshed)
|
||||
@@ -1235,24 +1407,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
)
|
||||
);
|
||||
|
||||
let row: (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
serde_json::Value,
|
||||
i64,
|
||||
) = sqlx::query_as(
|
||||
"select name, email, plan_type, provider_credentials_json, credential_revision
|
||||
let row = sqlx::query_as::<_, CoreRefreshRow>(
|
||||
"select name, email, plan_type, provider_credentials_json, credential_revision,
|
||||
credential_state, last_error_reason, last_error_message,
|
||||
quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at
|
||||
from provider_accounts where id = 'acct_core_refresh'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load refreshed account");
|
||||
assert_eq!(row.0, "after refresh");
|
||||
assert_eq!(row.1.as_deref(), Some("after@example.invalid"));
|
||||
assert_eq!(row.2.as_deref(), Some("premium"));
|
||||
assert_eq!(row.3["access_token"], "after-secret");
|
||||
assert_eq!(row.4, 2);
|
||||
assert_eq!(row.name, "after refresh");
|
||||
assert_eq!(row.email.as_deref(), Some("after@example.invalid"));
|
||||
assert_eq!(row.plan_type.as_deref(), Some("premium"));
|
||||
assert_eq!(
|
||||
row.provider_credentials_json["access_token"],
|
||||
"after-secret"
|
||||
);
|
||||
assert_eq!(row.credential_revision, 2);
|
||||
assert_eq!(row.credential_state, "ready");
|
||||
assert_eq!(row.last_error_reason, None);
|
||||
assert_eq!(row.last_error_message, None);
|
||||
assert_eq!(row.quota_access_state, "exhausted");
|
||||
assert_eq!(row.quota_evidence.as_deref(), Some("usage_limit_reached"));
|
||||
assert_eq!(
|
||||
row.quota_access_observed_at
|
||||
.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(quota_observed_at).timestamp_micros())
|
||||
);
|
||||
assert_eq!(
|
||||
row.quota_reset_at.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(quota_reset_at).timestamp_micros())
|
||||
);
|
||||
|
||||
assert!(
|
||||
repository
|
||||
|
||||
@@ -25,8 +25,9 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -49,7 +50,9 @@ use crate::credential::{
|
||||
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
|
||||
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
};
|
||||
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
|
||||
use crate::credential::{
|
||||
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
|
||||
};
|
||||
use crate::transport::CodexWebSocketPool;
|
||||
use crate::transport::profile::{
|
||||
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
|
||||
@@ -380,8 +383,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
.oauth()
|
||||
.and_then(|oauth| oauth.id_token.clone());
|
||||
}
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let prepared = CodexCredentialAdmin
|
||||
.prepare_refreshed_oauth_rotation(current, secret, None, None)
|
||||
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
|
||||
.map_err(map_credential_admin_error)?;
|
||||
prepared_rotation(prepared, command.account.provider_kind)
|
||||
}
|
||||
@@ -546,15 +551,16 @@ fn prepared_rotation(
|
||||
provider_kind: ProviderKind,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
let (profile, credential, replacement_identity, guard) = prepared.into_parts();
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
credential_profile,
|
||||
profile: credential_profile,
|
||||
credential,
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = credential.into_parts();
|
||||
account_state: _account_state,
|
||||
} = credential.into_parts();
|
||||
if profile != credential_profile || profile.account_id != account_id {
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
|
||||
}
|
||||
|
||||
@@ -819,7 +819,13 @@ impl CodexCredentialAdminService {
|
||||
.await
|
||||
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
|
||||
.map_err(map_refresh_failure)?;
|
||||
let access_token_rotated = tokens.access_token.is_some();
|
||||
let access_token_expires_at = match tokens.access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token),
|
||||
None => current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
};
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: tokens
|
||||
.access_token
|
||||
@@ -839,17 +845,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from)
|
||||
} else {
|
||||
current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from)
|
||||
};
|
||||
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
|
||||
// 之前瞬态失败留下的 retry-not-before。
|
||||
let next_refresh_at = None;
|
||||
@@ -1016,6 +1011,7 @@ impl CodexCredentialAdminService {
|
||||
let access_token = tokens
|
||||
.access_token
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let access_token_expires_at = parse_access_token_expiration(&access_token);
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -1030,10 +1026,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
Ok((secret, access_token_expires_at))
|
||||
}
|
||||
|
||||
@@ -1173,6 +1165,8 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
|
||||
failure_class = error.classification(),
|
||||
upstream_message = ?error.message(),
|
||||
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
|
||||
"OpenAI OAuth manual refresh failed"
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
|
||||
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
|
||||
};
|
||||
pub(crate) use oauth::oauth_owner_ref;
|
||||
pub(crate) use types::parse_access_token_expiration;
|
||||
|
||||
pub use agent_identity::{
|
||||
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Codex Authorization Code + PKCE/OIDC 管理流。
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -35,7 +31,7 @@ use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
};
|
||||
use super::types::parse_chatgpt_jwt_claims;
|
||||
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
|
||||
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
|
||||
@@ -527,13 +523,12 @@ impl CodexOAuthAdminService {
|
||||
.map(current_oauth_refresh_token)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let (mut secret, id_token, expires_in) = self
|
||||
let (mut secret, id_token) = self
|
||||
.exchange_pending(&pending, callback_url, fallback_refresh_token)
|
||||
.await?;
|
||||
let mutation = pending.mutation.clone();
|
||||
let access_token_expires_at = expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
|
||||
secret.id_token = Some(id_token);
|
||||
@@ -671,14 +666,7 @@ impl CodexOAuthAdminService {
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
fallback_refresh_token: Option<SecretString>,
|
||||
) -> Result<
|
||||
(
|
||||
super::types::CodexOAuthSecret,
|
||||
SecretString,
|
||||
Option<Duration>,
|
||||
),
|
||||
CodexOAuthAdminError,
|
||||
> {
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
if !constant_time_equal(
|
||||
pending.state.expose_secret().as_bytes(),
|
||||
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
Ok((secret, tokens.id_token, tokens.expires_in))
|
||||
Ok((secret, tokens.id_token))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ use chrono::Utc;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
|
||||
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
|
||||
QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::provider_ports::{ProviderCooldown, ProviderCooldownPort};
|
||||
use gateway_protocol::openai::events::{
|
||||
@@ -646,7 +646,25 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !quota_refresh_can_update(account, &snapshot, previous.as_ref()) {
|
||||
if account.quota().is_exhausted()
|
||||
&& !quota_refresh_allows_recovery(account, &snapshot, previous.as_ref())
|
||||
{
|
||||
let outcome = self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
observed_at,
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
summary.stale += 1;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
self.scheduling
|
||||
.observe(&previous.with_observed_at(observed_at));
|
||||
}
|
||||
summary.exhausted += 1;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -930,8 +948,29 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !quota_refresh_can_update(&account, &snapshot, previous.as_ref()) {
|
||||
return Ok(previous.unwrap_or_else(|| snapshot.with_quota_state(account.quota())));
|
||||
if !quota_refresh_allows_recovery(&account, &snapshot, previous.as_ref()) {
|
||||
if self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
observed_at,
|
||||
})
|
||||
.await?
|
||||
== QuotaWriteOutcome::Conflict
|
||||
{
|
||||
return Err(CodexCredentialQuotaError::RevisionConflict);
|
||||
}
|
||||
return Ok(match previous {
|
||||
Some(previous) => {
|
||||
let previous = previous.with_observed_at(observed_at);
|
||||
self.scheduling.observe(&previous);
|
||||
previous
|
||||
}
|
||||
None => snapshot
|
||||
.with_quota_state(account.quota())
|
||||
.with_observed_at(observed_at),
|
||||
});
|
||||
}
|
||||
let state = if account.quota().is_exhausted() {
|
||||
QuotaState::allowed(observed_at)
|
||||
@@ -1027,7 +1066,7 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_refresh_can_update(
|
||||
fn quota_refresh_allows_recovery(
|
||||
account: &ProviderAccount,
|
||||
refreshed: &CodexAccountQuotaSnapshot,
|
||||
previous: Option<&CodexAccountQuotaSnapshot>,
|
||||
|
||||
@@ -151,6 +151,13 @@ impl CodexAccountQuotaSnapshot {
|
||||
self.quota = quota;
|
||||
self
|
||||
}
|
||||
|
||||
/// 只更新展示与调度使用的最后成功查询时间,保留既有额度事实。
|
||||
#[must_use]
|
||||
pub(crate) const fn with_observed_at(mut self, observed_at: SystemTime) -> Self {
|
||||
self.observed_at = observed_at;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexQuotaFact {
|
||||
|
||||
@@ -21,7 +21,7 @@ use thiserror::Error;
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
|
||||
use super::types::CodexOAuthSecret;
|
||||
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const MAX_REFRESH_BATCH: u32 = 1_000;
|
||||
@@ -32,7 +32,7 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
|
||||
/// 耗尽指数退避后的 OAuth 恢复周期。
|
||||
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
/// 过期 AT 仍允许 RT 恢复的最长窗口。
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
|
||||
pub(crate) fn refresh_recovery_deadline(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
@@ -101,6 +101,8 @@ fn log_refresh_deferred(
|
||||
reason,
|
||||
upstream_message = ?upstream_message,
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
retry_at = %DateTime::<Utc>::from(retry_at),
|
||||
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
|
||||
@@ -273,20 +275,10 @@ impl CodexCredentialRefreshService {
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let account_id = due.account.id().to_string();
|
||||
if refresh_recovery_window_exhausted(
|
||||
let recovery_window_exhausted = refresh_recovery_window_exhausted(
|
||||
due.account.access_token_expires_at(),
|
||||
SystemTime::now(),
|
||||
) {
|
||||
return self
|
||||
.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Expired,
|
||||
AccountErrorReason::CredentialExpired,
|
||||
RefreshFailureContext::new("refresh_recovery_window_exhausted", None, None),
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
);
|
||||
let capacity = self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
|
||||
@@ -316,7 +308,44 @@ impl CodexCredentialRefreshService {
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
match self.refresher.refresh(refresh_token.expose_secret()).await {
|
||||
let refresh_result = self.refresher.refresh(refresh_token.expose_secret()).await;
|
||||
if recovery_window_exhausted && let Err(failure) = &refresh_result {
|
||||
let message = failure.message().map(str::to_owned);
|
||||
let upstream = failure.upstream();
|
||||
return match failure {
|
||||
RefreshFailure::Banned { .. } => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Banned,
|
||||
AccountErrorReason::AccountBanned,
|
||||
RefreshFailureContext::new(
|
||||
"refresh_recovery_window_exhausted",
|
||||
message,
|
||||
upstream,
|
||||
),
|
||||
CodexCredentialRefreshOutcome::Banned { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
RefreshFailure::InvalidGrant { .. }
|
||||
| RefreshFailure::RetryableTransport { .. }
|
||||
| RefreshFailure::Transport { .. } => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Expired,
|
||||
AccountErrorReason::CredentialExpired,
|
||||
RefreshFailureContext::new(
|
||||
"refresh_recovery_window_exhausted",
|
||||
message,
|
||||
upstream,
|
||||
),
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
}
|
||||
match refresh_result {
|
||||
Ok(tokens) => self.persist_success(due, tokens).await,
|
||||
Err(RefreshFailure::InvalidGrant { message, upstream }) => {
|
||||
self.persist_terminal(
|
||||
@@ -353,8 +382,8 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
}
|
||||
Err(RefreshFailure::Transport { message, upstream }) => {
|
||||
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
|
||||
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
|
||||
// 上游瞬态(401/429/5xx/超时/畸形响应等)保留现有凭据、
|
||||
// 记录最近一次失败并推进有界退避。
|
||||
if self
|
||||
.defer_refresh(
|
||||
&due.account,
|
||||
@@ -437,9 +466,11 @@ impl CodexCredentialRefreshService {
|
||||
access_token,
|
||||
refresh_token: rotated_refresh_token,
|
||||
id_token: rotated_id_token,
|
||||
expires_in,
|
||||
} = tokens;
|
||||
let access_token_rotated = access_token.is_some();
|
||||
let access_token_expires_at = match access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
|
||||
None => due.account.access_token_expires_at(),
|
||||
};
|
||||
let CodexOAuthSecret {
|
||||
access_token: current_access_token,
|
||||
refresh_token: current_refresh_token,
|
||||
@@ -466,12 +497,6 @@ impl CodexCredentialRefreshService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
let observed_at = SystemTime::now();
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
expires_in.and_then(|value| observed_at.checked_add(value))
|
||||
} else {
|
||||
due.account.access_token_expires_at()
|
||||
};
|
||||
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
|
||||
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
|
||||
let next_refresh_at = None;
|
||||
@@ -534,7 +559,12 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let persisted_message = message.as_deref().unwrap_or(reason).to_owned();
|
||||
let persisted_message = message
|
||||
.as_ref()
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(String::as_str)
|
||||
.or_else(|| account.last_error_message())
|
||||
.map(str::to_owned);
|
||||
match self
|
||||
.repository
|
||||
.apply_state_with_reason(
|
||||
@@ -542,7 +572,7 @@ impl CodexCredentialRefreshService {
|
||||
credential_state,
|
||||
SystemTime::now(),
|
||||
Some(error_reason),
|
||||
Some(persisted_message),
|
||||
persisted_message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -553,6 +583,8 @@ impl CodexCredentialRefreshService {
|
||||
reason,
|
||||
upstream_message = ?message.as_deref(),
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
access_token_expires_at = ?account.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
@@ -590,7 +622,20 @@ impl CodexCredentialRefreshService {
|
||||
attempt,
|
||||
reason,
|
||||
)?;
|
||||
match self.repository.defer_refresh(account, retry_at).await {
|
||||
let message = upstream_message
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(str::to_owned);
|
||||
let error_reason = upstream.map(|_| {
|
||||
account
|
||||
.credential_state()
|
||||
.error_reason()
|
||||
.unwrap_or(AccountErrorReason::AccessTokenExpired)
|
||||
});
|
||||
match self
|
||||
.repository
|
||||
.defer_refresh(account, retry_at, error_reason, message)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log_refresh_deferred(
|
||||
account.id(),
|
||||
|
||||
@@ -120,15 +120,18 @@ impl CodexCredentialRepository {
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?
|
||||
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
/// token 签名边界不可用时,以相同 credential 推进持久刷新退避。
|
||||
/// 以相同 credential 原子推进刷新退避及本次上游错误事实。
|
||||
pub async fn defer_refresh(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
next_refresh_at: SystemTime,
|
||||
error_reason: Option<AccountErrorReason>,
|
||||
message: Option<String>,
|
||||
) -> Result<CredentialRevision, CredentialRepositoryError> {
|
||||
let current = self
|
||||
.store
|
||||
@@ -140,7 +143,7 @@ impl CodexCredentialRepository {
|
||||
let data = CodexCredentialCodec::decode_complete(¤t.credential)?;
|
||||
let has_refresh_token = data.has_refresh_token();
|
||||
let credential = CodexCredentialCodec::encode_complete(data)?;
|
||||
let update = CredentialCasUpdate::new(
|
||||
let mut update = CredentialCasUpdate::new(
|
||||
account.id().clone(),
|
||||
account.revision(),
|
||||
unchanged_profile(account),
|
||||
@@ -150,6 +153,14 @@ impl CodexCredentialRepository {
|
||||
Some(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
if let Some(error_reason) = error_reason {
|
||||
update = update.with_account_state(
|
||||
account.credential_state(),
|
||||
SystemTime::now(),
|
||||
Some(error_reason),
|
||||
message,
|
||||
);
|
||||
}
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
@@ -245,13 +256,11 @@ impl CodexCredentialRepository {
|
||||
if !account.enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let (error_reason, message) = if credential_state == CredentialState::Ready {
|
||||
(None, None)
|
||||
let message = message.filter(|value| !value.trim().is_empty());
|
||||
let error_reason = if credential_state == CredentialState::Ready {
|
||||
message.as_ref().and(error_reason)
|
||||
} else {
|
||||
(
|
||||
error_reason.or_else(|| credential_state.error_reason()),
|
||||
message.filter(|value| !value.trim().is_empty()),
|
||||
)
|
||||
error_reason.or_else(|| credential_state.error_reason())
|
||||
};
|
||||
self.store
|
||||
.apply_state_change(AccountStateChange {
|
||||
|
||||
@@ -720,7 +720,15 @@ impl CodexCredentialSelector {
|
||||
.map(chrono::DateTime::<chrono::Utc>::from),
|
||||
"OpenAI access token expired; retaining account for bounded OAuth refresh recovery"
|
||||
);
|
||||
return Ok(());
|
||||
return self
|
||||
.apply_credential_state(
|
||||
account,
|
||||
CredentialState::Ready,
|
||||
AccountErrorReason::AccessTokenExpired,
|
||||
now,
|
||||
message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
self.apply_credential_state(
|
||||
account,
|
||||
|
||||
@@ -27,9 +27,6 @@ pub struct TokenPair {
|
||||
pub refresh_token: Option<String>,
|
||||
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
|
||||
pub id_token: Option<String>,
|
||||
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
|
||||
/// exchange,只意味着调用方不能据此安排下一次刷新。
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenPair {
|
||||
@@ -45,7 +42,6 @@ impl fmt::Debug for TokenPair {
|
||||
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -121,13 +117,21 @@ impl RefreshFailure {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RefreshUpstreamFailure {
|
||||
status: u16,
|
||||
code: Option<String>,
|
||||
error_type: Option<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl RefreshUpstreamFailure {
|
||||
fn new(status: StatusCode, body: &[u8]) -> Self {
|
||||
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
|
||||
Self {
|
||||
status: status.as_u16(),
|
||||
code: error
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_owned),
|
||||
error_type: error
|
||||
.and_then(RefreshErrorResponse::error_type)
|
||||
.map(str::to_owned),
|
||||
body: String::from_utf8_lossy(body).into_owned(),
|
||||
}
|
||||
}
|
||||
@@ -137,6 +141,16 @@ impl RefreshUpstreamFailure {
|
||||
self.status
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn code(&self) -> Option<&str> {
|
||||
self.code.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_type(&self) -> Option<&str> {
|
||||
self.error_type.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(&self) -> &str {
|
||||
&self.body
|
||||
@@ -148,6 +162,8 @@ impl fmt::Debug for RefreshUpstreamFailure {
|
||||
formatter
|
||||
.debug_struct("RefreshUpstreamFailure")
|
||||
.field("status", &self.status)
|
||||
.field("code", &self.code)
|
||||
.field("error_type", &self.error_type)
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
@@ -182,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
|
||||
pub struct AuthorizationTokenSet {
|
||||
pub secret: crate::credential::CodexOAuthSecret,
|
||||
pub id_token: SecretString,
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationTokenSet {
|
||||
@@ -191,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
|
||||
.debug_struct("AuthorizationTokenSet")
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("id_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -273,7 +287,6 @@ struct RefreshTokenResponse {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -300,6 +313,8 @@ enum RefreshError {
|
||||
struct RefreshErrorDetails {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshErrorResponse {
|
||||
@@ -319,6 +334,13 @@ impl RefreshErrorResponse {
|
||||
}
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn error_type(&self) -> Option<&str> {
|
||||
match self.error.as_ref() {
|
||||
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
|
||||
Some(RefreshError::Code(_)) | None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -326,7 +348,6 @@ struct AuthorizationCodeResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
id_token: String,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -398,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -447,36 +467,43 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
|
||||
refresh_token: tokens.refresh_token,
|
||||
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
|
||||
id_token: tokens.id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
|
||||
value
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
|
||||
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
|
||||
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
|
||||
let message = Some(refresh_failure_message(error.as_ref(), body));
|
||||
let upstream = || Some(Box::new(RefreshUpstreamFailure::new(status, body)));
|
||||
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
|
||||
// 不据此终态失效账号,但官方 `error.message` 仍用于本次诊断。
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
let message = error.as_ref().and_then(RefreshErrorResponse::message);
|
||||
let upstream = || {
|
||||
Some(Box::new(RefreshUpstreamFailure::new(
|
||||
status,
|
||||
body,
|
||||
error.as_ref(),
|
||||
)))
|
||||
};
|
||||
let normalized_code = error
|
||||
.as_ref()
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_ascii_lowercase);
|
||||
// 生产策略故意与官方 Codex 的“任意 401 立即终态”不同:
|
||||
// 显式 401 先进入有界恢复退避,避免瞬时授权故障直接失效账号。
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
let Some(error) = error else {
|
||||
return RefreshFailure::Transport {
|
||||
// 非 401 响应仍与官方一致:三个明确的 RT 原因是永久失败。
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
|
||||
) {
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
};
|
||||
}
|
||||
if message.as_deref().is_some_and(|message| {
|
||||
message
|
||||
.to_ascii_lowercase()
|
||||
@@ -487,39 +514,12 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
let normalized_code = error.code().map(str::to_ascii_lowercase);
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some(
|
||||
"invalid_grant"
|
||||
| "invalid_token"
|
||||
| "access_denied"
|
||||
| "refresh_token_expired"
|
||||
| "refresh_token_reused"
|
||||
| "refresh_token_invalidated"
|
||||
)
|
||||
) {
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_failure_message(error: Option<&RefreshErrorResponse>, body: &[u8]) -> String {
|
||||
if let Some(message) = error.and_then(RefreshErrorResponse::message) {
|
||||
return message;
|
||||
}
|
||||
if body.is_empty() {
|
||||
return "Unknown error".to_owned();
|
||||
}
|
||||
String::from_utf8_lossy(body).into_owned()
|
||||
}
|
||||
|
||||
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
|
||||
let message = error.to_string();
|
||||
if is_safe_to_retry_refresh_transport(error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::num::NonZeroU32;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
@@ -762,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
|
||||
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_admin_rotation_expiration".to_owned(),
|
||||
name: "admin rotation expiration".to_owned(),
|
||||
secret: secret("admin-rotation-access"),
|
||||
verified_account: profile("chatgpt-admin-rotation-expiration"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account = store
|
||||
.account("acct_admin_rotation_expiration")
|
||||
.expect("stored account");
|
||||
let record = account_record(&account);
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let mut material = Map::new();
|
||||
material.insert(
|
||||
"access_token".to_owned(),
|
||||
Value::String(format!("unverified-header.{payload}.unverified-signature")),
|
||||
);
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
Value::String("admin-rotation-refresh".to_owned()),
|
||||
);
|
||||
|
||||
let prepared = bundle
|
||||
.admin_provider()
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: record,
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
|
||||
})
|
||||
.await
|
||||
.expect("JWT rotation should be prepared");
|
||||
|
||||
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
|
||||
}
|
||||
|
||||
fn provider_ports() -> ProviderStorePorts {
|
||||
provider_ports_with(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
|
||||
@@ -812,13 +812,15 @@ fn credential_expired_failure_keeps_expired_oauth_for_bounded_refresh_recovery()
|
||||
))
|
||||
.expect("record credential expiry");
|
||||
|
||||
let retained = store
|
||||
.account("acct_primary")
|
||||
.expect("account retained for refresh");
|
||||
assert_eq!(retained.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("account retained for refresh")
|
||||
.credential_state(),
|
||||
CredentialState::Ready
|
||||
retained.last_error_reason(),
|
||||
Some(AccountErrorReason::AccessTokenExpired)
|
||||
);
|
||||
assert_eq!(retained.last_error_message(), Some("token_expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -112,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from(self.id_token.clone()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ async fn quota_refresh_updates_access_fact_without_recovering_credential_error()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high() {
|
||||
async fn exhausted_quota_worker_only_touches_refresh_time_when_recovery_is_rejected() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_worker_quota_recovery_gate";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -101,7 +101,7 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
service
|
||||
let old_snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
@@ -125,6 +125,14 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(summary.updated, 0);
|
||||
assert_eq!(store.quota_json(account_id), Some(old_quota));
|
||||
let refreshed = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read refreshed quota")
|
||||
.expect("refreshed quota snapshot");
|
||||
assert!(refreshed.observed_at() > old_snapshot.observed_at());
|
||||
assert_eq!(refreshed.fact(), old_snapshot.fact());
|
||||
assert_eq!(refreshed.quota().access(), QuotaAccessState::Exhausted);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
@@ -137,10 +145,18 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_percent() {
|
||||
for (suffix, reset_at, used_percent, recovered) in [
|
||||
("same_reset", 1_900_000_000_i64, 0_u64, false),
|
||||
("exactly_ten", 1_900_003_600_i64, 10_u64, false),
|
||||
("below_ten", 1_900_003_600_i64, 9_u64, true),
|
||||
for (suffix, allowed, limit_reached, reset_at, used_percent, recovered) in [
|
||||
(
|
||||
"still_exhausted",
|
||||
false,
|
||||
true,
|
||||
1_900_003_600_i64,
|
||||
0_u8,
|
||||
false,
|
||||
),
|
||||
("same_reset", true, false, 1_900_000_000_i64, 0_u8, false),
|
||||
("exactly_ten", true, false, 1_900_003_600_i64, 10_u8, false),
|
||||
("below_ten", true, false, 1_900_003_600_i64, 9_u8, true),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = format!("acct_manual_quota_recovery_{suffix}");
|
||||
@@ -166,7 +182,7 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
service
|
||||
let old_snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
@@ -177,8 +193,8 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"allowed": allowed,
|
||||
"limit_reached": limit_reached,
|
||||
"primary_window": {"used_percent": used_percent, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
@@ -197,11 +213,12 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
assert_ne!(store.quota_json(&account_id), Some(old_quota));
|
||||
assert_eq!(current.quota().access(), QuotaAccessState::Allowed);
|
||||
} else {
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
assert_eq!(snapshot.fact(), old_snapshot.fact());
|
||||
assert_eq!(snapshot.quota().access(), QuotaAccessState::Exhausted);
|
||||
assert_eq!(store.quota_json(&account_id), Some(old_quota));
|
||||
assert_eq!(current.quota().access(), QuotaAccessState::Exhausted);
|
||||
}
|
||||
assert!(snapshot.observed_at() > old_snapshot.observed_at());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountStateChange, AccountStatus, CredentialState, ProviderAccountId,
|
||||
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
@@ -14,12 +18,16 @@ use gateway_core::provider_ports::{
|
||||
ProviderSchedulingState, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::token_client::{
|
||||
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenPair, TokenRefresher,
|
||||
};
|
||||
use provider_openai::credential::{
|
||||
CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
|
||||
ImportCodexOAuthCredential,
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
|
||||
@@ -36,7 +44,6 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -52,7 +59,17 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: Some(id_token.to_owned()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn with_access_token(access_token: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
response: Mutex::new(Some(TokenPair {
|
||||
access_token: Some(access_token),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -64,7 +81,6 @@ impl SingleUseRefresher {
|
||||
access_token: None,
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -281,6 +297,66 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_rotates_tokens_while_quota_is_exhausted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
|
||||
let account_id = "acct_exhausted_quota_refresh";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let account = store.account(account_id).expect("seeded account");
|
||||
assert_eq!(
|
||||
store
|
||||
.apply_quota_access(QuotaAccessChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
SystemTime::now(),
|
||||
None,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: refreshed_account_id,
|
||||
..
|
||||
}] if refreshed_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("refreshed account");
|
||||
assert!(account.quota().is_exhausted());
|
||||
let loaded = store
|
||||
.load_credential(account.id(), account.revision())
|
||||
.await
|
||||
.expect("refreshed credential");
|
||||
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
|
||||
let oauth = runtime.authentication.oauth().expect("OAuth credential");
|
||||
assert_eq!(oauth.access_token.expose_secret(), "refreshed-access-token");
|
||||
let expected_refresh_token = format!("rt-access-{account_id}");
|
||||
assert_eq!(
|
||||
oauth
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(SecretString::expose_secret),
|
||||
Some(expected_refresh_token.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -326,6 +402,158 @@ async fn scheduled_refresh_persists_the_original_upstream_error_message() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let upstream_message = "Invalid refresh token.";
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": upstream_message,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_refresh_token"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(OpenAiTokenClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("test HTTP client"),
|
||||
TokenClientConfig {
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
)),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
policy,
|
||||
);
|
||||
let account_id = "acct_retryable_unauthorized";
|
||||
let expires_at = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(30 * 60))
|
||||
.expect("expired access token");
|
||||
seed_refreshable_account(&store, account_id, expires_at, None).await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: deferred_account_id,
|
||||
}] if deferred_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("deferred account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(
|
||||
account.last_error_reason(),
|
||||
Some(AccountErrorReason::AccessTokenExpired)
|
||||
);
|
||||
assert_eq!(account.last_error_message(), Some(upstream_message));
|
||||
let retry_at = account.next_refresh_at().expect("next refresh attempt");
|
||||
assert!(retry_at > SystemTime::now());
|
||||
assert!(
|
||||
retry_at
|
||||
<= expires_at
|
||||
.checked_add(Duration::from_secs(2 * 60 * 60))
|
||||
.expect("recovery deadline")
|
||||
);
|
||||
let projection = account.status_projection(SystemTime::now(), None);
|
||||
assert_eq!(projection.status, AccountStatus::Error);
|
||||
assert_eq!(projection.error_message.as_deref(), Some(upstream_message));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_final_message_after_the_two_hour_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let upstream_message = "Invalid refresh token.";
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(FailingRefresher {
|
||||
failure: RefreshFailure::Transport {
|
||||
message: Some(upstream_message.to_owned()),
|
||||
upstream: None,
|
||||
},
|
||||
}),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
policy,
|
||||
);
|
||||
let account_id = "acct_exhausted_unauthorized";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(2 * 60 * 60 + 60))
|
||||
.expect("expired recovery window"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated {
|
||||
account_id: invalidated_account_id,
|
||||
}] if invalidated_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("invalidated account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Expired);
|
||||
assert_eq!(account.last_error_message(), Some(upstream_message));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_success_clears_a_deferred_failure_message() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let account_id = "acct_recovered_after_backoff";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.expect("expired access token"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let account = store.account(account_id).expect("seeded account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
credential_state: CredentialState::Ready,
|
||||
observed_at: SystemTime::now(),
|
||||
error_reason: Some(AccountErrorReason::AccessTokenExpired),
|
||||
message: Some("Invalid refresh token.".to_owned()),
|
||||
})
|
||||
.await
|
||||
.expect("seed deferred failure");
|
||||
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: refreshed_account_id,
|
||||
..
|
||||
}] if refreshed_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("refreshed account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(account.last_error_reason(), None);
|
||||
assert_eq!(account.last_error_message(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_a_returned_id_token() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -361,6 +589,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let refresher = SingleUseRefresher::with_access_token(format!(
|
||||
"unverified-header.{payload}.unverified-signature"
|
||||
));
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = refresh_service(
|
||||
&store,
|
||||
refresher,
|
||||
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
|
||||
);
|
||||
let account_id = "acct_rotated_access_expiration";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("refreshed account")
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
Some(expires_at)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use provider_openai::credential::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
|
||||
@@ -55,7 +53,7 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
@@ -77,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
|
||||
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -122,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
|
||||
assert!(tokens.access_token.is_none());
|
||||
assert!(tokens.refresh_token.is_none());
|
||||
assert!(tokens.id_token.is_none());
|
||||
assert!(tokens.expires_in.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -305,23 +301,66 @@ async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
|
||||
assert_eq!(message.as_deref(), Some(upstream_message));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 400);
|
||||
assert_eq!(upstream.code(), Some("refresh_token_reused"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_error_without_nested_message_should_use_the_complete_body() {
|
||||
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
|
||||
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
|
||||
let failure = refresh_failure(400, body).await;
|
||||
let diagnostic = format!("{failure:?} {failure}");
|
||||
|
||||
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
|
||||
panic!("invalid_grant must be terminal");
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("unknown official refresh code must remain transient");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some(body));
|
||||
assert_eq!(upstream.expect("complete upstream failure").body(), body);
|
||||
assert_eq!(message, None);
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.code(), Some("invalid_grant"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
assert!(!diagnostic.contains("error_description"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_should_preserve_upstream_details_and_remain_retryable() {
|
||||
let body = r#"{
|
||||
"error": {
|
||||
"message": "Invalid refresh token.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "invalid_refresh_token"
|
||||
}
|
||||
}"#;
|
||||
let failure = refresh_failure(401, body).await;
|
||||
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("production policy gives every 401 a bounded recovery window");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 401);
|
||||
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_should_back_off_even_with_a_recognized_refresh_code() {
|
||||
let failure = refresh_failure(
|
||||
401,
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_transport_failure(
|
||||
&failure,
|
||||
401,
|
||||
Some("Refresh token expired."),
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivated_account_should_be_classified_as_banned() {
|
||||
let failure = refresh_failure(
|
||||
@@ -341,50 +380,60 @@ async fn deactivated_account_should_be_classified_as_banned() {
|
||||
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
|
||||
let failure = refresh_failure(403, "account is banned").await;
|
||||
|
||||
assert_transport_failure(&failure, 403, "account is banned", "account is banned");
|
||||
assert_transport_failure(&failure, 403, None, "account is banned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_disabled_account_text_should_remain_a_transport_failure() {
|
||||
let failure = refresh_failure(400, "account disabled").await;
|
||||
|
||||
assert_transport_failure(&failure, 400, "account disabled", "account disabled");
|
||||
assert_transport_failure(&failure, 400, None, "account disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_text_should_not_disable_the_oauth_credential() {
|
||||
let failure = refresh_failure(400, "quota exceeded").await;
|
||||
|
||||
assert_transport_failure(&failure, 400, "quota exceeded", "quota exceeded");
|
||||
assert_transport_failure(&failure, 400, None, "quota exceeded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
|
||||
let failure = refresh_failure(400, "token_revoked").await;
|
||||
|
||||
assert_transport_failure(&failure, 400, "token_revoked", "token_revoked");
|
||||
assert_transport_failure(&failure, 400, None, "token_revoked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
|
||||
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
|
||||
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
|
||||
async fn recognized_refresh_code_should_be_permanent_on_non_unauthorized_status() {
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(
|
||||
status,
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_transport_failure(
|
||||
&failure,
|
||||
status,
|
||||
"Refresh token expired.",
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
assert!(
|
||||
matches!(failure, RefreshFailure::InvalidGrant { .. }),
|
||||
"official refresh code must take precedence for status {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str, body: &str) {
|
||||
#[tokio::test]
|
||||
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
|
||||
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(status, body).await;
|
||||
assert_transport_failure(&failure, status, Some("Try again."), body);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_transport_failure(
|
||||
failure: &RefreshFailure,
|
||||
status: u16,
|
||||
message: Option<&str>,
|
||||
body: &str,
|
||||
) {
|
||||
let RefreshFailure::Transport {
|
||||
message: actual_message,
|
||||
upstream,
|
||||
@@ -392,7 +441,7 @@ fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str
|
||||
else {
|
||||
panic!("status {status} must classify as transient");
|
||||
};
|
||||
assert_eq!(actual_message.as_deref(), Some(message));
|
||||
assert_eq!(actual_message.as_deref(), message);
|
||||
let upstream = upstream.as_deref().expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), status);
|
||||
assert_eq!(upstream.body(), body);
|
||||
|
||||
@@ -10,10 +10,10 @@ use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountRuntimeSignals, AccountStateChange,
|
||||
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
|
||||
LoadedCredential, NewProviderAccount, OpaqueProviderData, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
|
||||
CredentialRevision, CredentialState, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
ProviderAccount, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaAccessChange, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
@@ -207,7 +207,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, StoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -215,7 +215,8 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
.get_mut(&account_id)
|
||||
@@ -226,15 +227,29 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
let next = expected_revision
|
||||
.next()
|
||||
.map_err(|_| store_error(StoreErrorKind::Conflict))?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map_or(stored.account.credential_state(), |state| {
|
||||
state.credential_state
|
||||
});
|
||||
let last_error_reason = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_reason(),
|
||||
|state| state.error_reason,
|
||||
);
|
||||
let last_error_message = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_message().map(str::to_owned),
|
||||
|state| state.message.clone(),
|
||||
);
|
||||
let state_observed_at = account_state.as_ref().map(|state| state.observed_at);
|
||||
stored.account = rebuild_account(
|
||||
&stored.account,
|
||||
AccountRebuild {
|
||||
revision: next,
|
||||
enabled: stored.account.enabled(),
|
||||
credential_state: stored.account.credential_state(),
|
||||
credential_state,
|
||||
quota: stored.account.quota(),
|
||||
last_error_reason: stored.account.last_error_reason(),
|
||||
last_error_message: stored.account.last_error_message().map(str::to_owned),
|
||||
last_error_reason,
|
||||
last_error_message,
|
||||
access_token_expires_at,
|
||||
has_refresh_token,
|
||||
next_refresh_at,
|
||||
@@ -243,6 +258,9 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
);
|
||||
stored.credential = credential;
|
||||
stored.quota = None;
|
||||
if let Some(observed_at) = state_observed_at {
|
||||
stored.state_observed_at = Some(observed_at);
|
||||
}
|
||||
Ok(CredentialCasOutcome::Updated(next))
|
||||
}
|
||||
|
||||
@@ -285,6 +303,27 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
.get_mut(&touch.account_id)
|
||||
.ok_or_else(|| store_error(StoreErrorKind::InvalidData))?;
|
||||
if stored.account.revision() != touch.expected_revision {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let Some(quota) = &mut stored.quota else {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
};
|
||||
if quota.observed_at > touch.observed_at {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
quota.observed_at = touch.observed_at;
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
|
||||
@@ -27,8 +27,9 @@ use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision}
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -906,15 +907,16 @@ fn prepared_rotation(
|
||||
provider_kind: ProviderKind,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
let (profile, credential, guard) = prepared.into_parts();
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
credential_profile,
|
||||
profile: credential_profile,
|
||||
credential,
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = credential.into_parts();
|
||||
account_state: _account_state,
|
||||
} = credential.into_parts();
|
||||
if profile != credential_profile || profile.account_id != account_id {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Internal));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
|
||||
LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts, CredentialRevision,
|
||||
CredentialState, LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount,
|
||||
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange,
|
||||
QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::provider_ports::{
|
||||
@@ -327,7 +327,7 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, StoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -335,22 +335,36 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
let mut accounts = lock(&self.accounts);
|
||||
let stored = accounts.get_mut(&account_id).ok_or_else(invalid)?;
|
||||
if stored.account.revision() != expected_revision {
|
||||
return Ok(CredentialCasOutcome::Conflict);
|
||||
}
|
||||
let next = expected_revision.next().map_err(|_| invalid())?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map_or(stored.account.credential_state(), |state| {
|
||||
state.credential_state
|
||||
});
|
||||
let last_error_reason = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_reason(),
|
||||
|state| state.error_reason,
|
||||
);
|
||||
let last_error_message = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_message().map(str::to_owned),
|
||||
|state| state.message.clone(),
|
||||
);
|
||||
stored.account = rebuild_account(
|
||||
&stored.account,
|
||||
AccountReplacement {
|
||||
revision: next,
|
||||
access_token_expires_at,
|
||||
credential_state: stored.account.credential_state(),
|
||||
credential_state,
|
||||
quota: stored.account.quota(),
|
||||
last_error_reason: stored.account.last_error_reason(),
|
||||
last_error_message: stored.account.last_error_message().map(str::to_owned),
|
||||
last_error_reason,
|
||||
last_error_message,
|
||||
enabled: stored.account.enabled(),
|
||||
has_refresh_token,
|
||||
next_refresh_at,
|
||||
@@ -396,6 +410,25 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = lock(&self.accounts);
|
||||
let stored = accounts.get_mut(&touch.account_id).ok_or_else(invalid)?;
|
||||
if stored.account.revision() != touch.expected_revision {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let Some(quota) = &mut stored.quota else {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
};
|
||||
if quota.observed_at > touch.observed_at {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
quota.observed_at = touch.observed_at;
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.30.0",
|
||||
"@lucide/vue": "^1.31.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
@@ -26,7 +26,7 @@
|
||||
"es-toolkit": "^1.50.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.9",
|
||||
"pinia": "^4.0.2",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
@@ -39,10 +39,10 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.9"
|
||||
"vue-tsc": "^3.3.10"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+33
-33
@@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@lucide/vue':
|
||||
specifier: ^1.30.0
|
||||
version: 1.30.0(vue@3.5.41(typescript@6.0.3))
|
||||
specifier: ^1.31.0
|
||||
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.4.0
|
||||
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||
@@ -45,21 +45,21 @@ importers:
|
||||
specifier: ^18.0.9
|
||||
version: 18.0.9
|
||||
pinia:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
|
||||
version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
|
||||
vue:
|
||||
specifier: ^3.5.41
|
||||
version: 3.5.41(typescript@6.0.3)
|
||||
vue-router:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))
|
||||
@@ -79,8 +79,8 @@ importers:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(eslint@10.8.1(jiti@2.7.0))
|
||||
eslint-plugin-vuejs-accessibility:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
tailwindcss:
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3
|
||||
@@ -91,8 +91,8 @@ importers:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue-tsc:
|
||||
specifier: ^3.3.9
|
||||
version: 3.3.9(typescript@6.0.3)
|
||||
specifier: ^3.3.10
|
||||
version: 3.3.10(typescript@6.0.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -354,8 +354,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lucide/vue@1.30.0':
|
||||
resolution: {integrity: sha512-EF+N6eH/zaaZN0EY0y0URvKjMAj4QUeVtdDeFxjWHjhXihve/pBge+ahaeXLtqa1np0Ax5vsZEIsOTcsGZwPNQ==}
|
||||
'@lucide/vue@1.31.0':
|
||||
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -849,8 +849,8 @@ packages:
|
||||
'@vue/devtools-shared@8.2.1':
|
||||
resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
|
||||
|
||||
'@vue/language-core@3.3.9':
|
||||
resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
|
||||
'@vue/language-core@3.3.10':
|
||||
resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==}
|
||||
|
||||
'@vue/reactivity@3.5.41':
|
||||
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
|
||||
@@ -1297,8 +1297,8 @@ packages:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
|
||||
eslint-plugin-vuejs-accessibility@2.5.0:
|
||||
resolution: {integrity: sha512-oZ2fL4tS91Cm/ezH3BueNP+FtpbbeS627OSqqgp9/lsN//glmoPcLBT6D53xwGocLtyBybaT99tX4ThBh8+ytA==}
|
||||
eslint-plugin-vuejs-accessibility@2.6.0:
|
||||
resolution: {integrity: sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
@@ -2045,8 +2045,8 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
pinia@4.0.2:
|
||||
resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==}
|
||||
pinia@4.0.3:
|
||||
resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==}
|
||||
peerDependencies:
|
||||
'@vue/devtools-api': ^8.1.5
|
||||
typescript: '>=5.6.0'
|
||||
@@ -2377,8 +2377,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vue-tsc@3.3.9:
|
||||
resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
|
||||
vue-tsc@3.3.10:
|
||||
resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
@@ -2431,7 +2431,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
|
||||
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 2.0.1
|
||||
'@clack/prompts': 1.7.0
|
||||
@@ -2472,7 +2472,7 @@ snapshots:
|
||||
yaml-eslint-parser: 2.1.0
|
||||
optionalDependencies:
|
||||
eslint-plugin-format: 2.0.1(eslint@10.8.1(jiti@2.7.0))
|
||||
eslint-plugin-vuejs-accessibility: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
eslint-plugin-vuejs-accessibility: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@eslint/json'
|
||||
- '@typescript-eslint/typescript-estree'
|
||||
@@ -2687,7 +2687,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
|
||||
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
|
||||
dependencies:
|
||||
vue: 3.5.41(typescript@6.0.3)
|
||||
|
||||
@@ -3086,7 +3086,7 @@ snapshots:
|
||||
|
||||
'@vue/devtools-shared@8.2.1': {}
|
||||
|
||||
'@vue/language-core@3.3.9':
|
||||
'@vue/language-core@3.3.10':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
@@ -3551,7 +3551,7 @@ snapshots:
|
||||
'@stylistic/eslint-plugin': 5.10.0(eslint@10.8.1(jiti@2.7.0))
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
|
||||
eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
|
||||
eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
|
||||
dependencies:
|
||||
aria-query: 5.3.2
|
||||
eslint: 10.8.1(jiti@2.7.0)
|
||||
@@ -4423,13 +4423,13 @@ snapshots:
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
pinia-plugin-persistedstate@4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
|
||||
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
|
||||
dependencies:
|
||||
defu: 6.1.7
|
||||
optionalDependencies:
|
||||
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
|
||||
pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
|
||||
pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 8.1.5
|
||||
nostics: 1.2.0
|
||||
@@ -4685,7 +4685,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@6.0.3))
|
||||
@@ -4708,7 +4708,7 @@ snapshots:
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.41
|
||||
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@farmfe/core'
|
||||
@@ -4720,10 +4720,10 @@ snapshots:
|
||||
- unloader
|
||||
- webpack
|
||||
|
||||
vue-tsc@3.3.9(typescript@6.0.3):
|
||||
vue-tsc@3.3.10(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
'@vue/language-core': 3.3.9
|
||||
'@vue/language-core': 3.3.10
|
||||
typescript: 6.0.3
|
||||
|
||||
vue@3.5.41(typescript@6.0.3):
|
||||
|
||||
@@ -392,179 +392,3 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery) {
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Usage 记录的规范化 view model:组件只消费这个形状,兼容性回退只发生在本文件。
|
||||
export interface UsageViewModel {
|
||||
id: string
|
||||
requestId: string
|
||||
clientApiKeyId: string | null
|
||||
kind: string
|
||||
provider: string | null
|
||||
authenticationKind: string | null
|
||||
accountId: string | null
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
route: string
|
||||
model: string
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
statusCode: number | null
|
||||
transport: string | null
|
||||
stream: boolean | null
|
||||
apiKind: string | null
|
||||
attemptIndex: number | null
|
||||
attemptCount: number
|
||||
responseId: string | null
|
||||
upstreamRequestId: string | null
|
||||
protocol: string
|
||||
httpVersion: string | null
|
||||
clientStatusCode: number | null
|
||||
upstreamStatusCode: number | null
|
||||
websocketPool: { kind: string } | null
|
||||
imageGenerationRequested: boolean
|
||||
imageGenerationSucceeded: boolean | null
|
||||
latencyMs: number | null
|
||||
firstTokenLatencyMs: number | null
|
||||
latencyDetails: UsageLatencyDetails | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cachedTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
reasoningTokens: number | null
|
||||
imageInputTokens: number | null
|
||||
imageOutputTokens: number | null
|
||||
message: string
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
clientIp: string | null
|
||||
userAgent: string | null
|
||||
reasoningEffort: string | null
|
||||
reasoningPreset: string | null
|
||||
compact: boolean
|
||||
requestKind: string | null
|
||||
subagentKind: string | null
|
||||
tokenDetails: UsageTokenDetails
|
||||
billing: UsageBilling | null
|
||||
costs: UsageCost[]
|
||||
costCoverage: UsageCostCoverage
|
||||
firstTokenLatencyMsDisplay: string
|
||||
latencyMsDisplay: string
|
||||
logicalOutcome: string
|
||||
providerMetadata: Record<string, unknown>
|
||||
requestBody?: unknown
|
||||
responseBody?: unknown
|
||||
attempts?: UsageAttempt[]
|
||||
attemptsComplete?: boolean
|
||||
}
|
||||
|
||||
const CORE_METADATA_KEYS = new Set([
|
||||
'protocol',
|
||||
'logicalOutcome',
|
||||
'attemptCount',
|
||||
'requestedModel',
|
||||
'upstreamModel',
|
||||
'clientIp',
|
||||
'userAgent',
|
||||
'reasoningEffort',
|
||||
'reasoningPreset',
|
||||
'compact',
|
||||
'requestKind',
|
||||
'subagentKind',
|
||||
'transport',
|
||||
'httpVersion',
|
||||
'clientStatusCode',
|
||||
'upstreamStatusCode',
|
||||
'responseId',
|
||||
'upstreamRequestId',
|
||||
'websocketPool',
|
||||
'imageGenerationRequested',
|
||||
'imageGenerationSucceeded',
|
||||
'latencyDetails',
|
||||
])
|
||||
|
||||
function metadataRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
/** 一次性兼容 normalize:顶层 Core 字段优先,旧响应中的 metadata 回退只发生在这里。 */
|
||||
export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): UsageViewModel {
|
||||
const metadata = metadataRecord(record.metadata)
|
||||
const legacy = <T>(top: T | null | undefined, key: string): T | null | undefined =>
|
||||
top ?? (metadata[key] as T | undefined)
|
||||
|
||||
const latencyDetails = legacy(record.latencyDetails, 'latencyDetails') ?? null
|
||||
const providerMetadata: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (!CORE_METADATA_KEYS.has(key))
|
||||
providerMetadata[key] = value
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
requestId: record.requestId,
|
||||
clientApiKeyId: record.clientApiKeyId,
|
||||
kind: record.kind,
|
||||
provider: record.provider,
|
||||
authenticationKind: record.authenticationKind,
|
||||
accountId: record.accountId,
|
||||
accountEmail: record.accountEmail,
|
||||
accountName: record.accountName,
|
||||
route: record.route,
|
||||
model: record.model,
|
||||
requestedModel: legacy(record.requestedModel, 'requestedModel') ?? null,
|
||||
upstreamModel: legacy(record.upstreamModel, 'upstreamModel') ?? null,
|
||||
serviceTier: record.serviceTier,
|
||||
statusCode: record.statusCode,
|
||||
transport: legacy(record.transport, 'transport') ?? null,
|
||||
stream: typeof metadata.stream === 'boolean' ? metadata.stream : null,
|
||||
apiKind: typeof metadata.apiKind === 'string' ? metadata.apiKind : null,
|
||||
attemptIndex: record.attemptIndex,
|
||||
attemptCount: legacy(record.attemptCount, 'attemptCount') ?? 0,
|
||||
responseId: legacy(record.responseId, 'responseId') ?? null,
|
||||
upstreamRequestId: legacy(record.upstreamRequestId, 'upstreamRequestId') ?? null,
|
||||
protocol: legacy(record.protocol, 'protocol') ?? '',
|
||||
httpVersion: legacy(record.httpVersion, 'httpVersion') ?? null,
|
||||
clientStatusCode: legacy(record.clientStatusCode, 'clientStatusCode') ?? null,
|
||||
upstreamStatusCode: legacy(record.upstreamStatusCode, 'upstreamStatusCode') ?? null,
|
||||
websocketPool: legacy(record.websocketPool, 'websocketPool') ?? null,
|
||||
imageGenerationRequested:
|
||||
legacy(record.imageGenerationRequested, 'imageGenerationRequested') ?? false,
|
||||
imageGenerationSucceeded:
|
||||
legacy(record.imageGenerationSucceeded, 'imageGenerationSucceeded') ?? null,
|
||||
latencyMs: record.latencyMs,
|
||||
firstTokenLatencyMs: record.firstTokenLatencyMs,
|
||||
latencyDetails,
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
cacheWriteTokens: record.cacheWriteTokens,
|
||||
reasoningTokens: record.reasoningTokens,
|
||||
imageInputTokens: record.imageInputTokens,
|
||||
imageOutputTokens: record.imageOutputTokens,
|
||||
message: record.message,
|
||||
createdAt: record.createdAt,
|
||||
createdAtDisplay: record.createdAtDisplay,
|
||||
clientIp: legacy(record.clientIp, 'clientIp') ?? null,
|
||||
userAgent: legacy(record.userAgent, 'userAgent') ?? null,
|
||||
reasoningEffort: legacy(record.reasoningEffort, 'reasoningEffort') ?? null,
|
||||
reasoningPreset: legacy(record.reasoningPreset, 'reasoningPreset') ?? null,
|
||||
compact: legacy(record.compact, 'compact') ?? false,
|
||||
requestKind: legacy(record.requestKind, 'requestKind') ?? null,
|
||||
subagentKind: legacy(record.subagentKind, 'subagentKind') ?? null,
|
||||
tokenDetails: record.tokenDetails,
|
||||
billing: record.billing,
|
||||
costs: record.costs,
|
||||
costCoverage: record.costCoverage,
|
||||
firstTokenLatencyMsDisplay: record.firstTokenLatencyMsDisplay,
|
||||
latencyMsDisplay: record.latencyMsDisplay,
|
||||
logicalOutcome: legacy(record.logicalOutcome, 'logicalOutcome') ?? '',
|
||||
providerMetadata,
|
||||
requestBody: metadata.requestBody,
|
||||
responseBody: metadata.responseBody,
|
||||
attempts: 'attempts' in record ? record.attempts : undefined,
|
||||
attemptsComplete: 'attemptsComplete' in record ? record.attemptsComplete : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -76,7 +76,7 @@ function toggle(groupId: string) {
|
||||
function providerSummary(group: AccountGroup) {
|
||||
return Object.entries(group.providerCounts)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([provider, count]) => `${providerDisplayName(provider) ?? provider} ${count}`)
|
||||
.map(([provider, count]) => `${formatProviderLabel(provider)} ${count}`)
|
||||
.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | null
|
||||
}>()
|
||||
|
||||
const displayValue = computed(() => props.value ? formatRelativeTime(props.value) : '')
|
||||
const now = useUiClock()
|
||||
const displayValue = computed(() => props.value ? formatRelativeTime(props.value, now.value) : '')
|
||||
const fullValue = computed(() => props.value ? formatDateTime(props.value) : undefined)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { LayoutGrid } from '@lucide/vue'
|
||||
|
||||
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
|
||||
import BaseSegmented from './base/BaseSegmented.vue'
|
||||
|
||||
withDefaults(
|
||||
@@ -17,8 +18,8 @@ const provider = defineModel<string>({ required: true })
|
||||
|
||||
const providerOptions = [
|
||||
{ label: '全部平台', value: '', icon: LayoutGrid },
|
||||
{ label: 'OpenAI', value: 'openai', icon: Openai },
|
||||
{ label: 'xAI', value: 'xai', icon: Xai },
|
||||
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
|
||||
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
|
||||
import { computed } from 'vue'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -25,9 +25,7 @@ const iconContainerClass = computed(() =>
|
||||
)
|
||||
const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
|
||||
|
||||
const providerLabel = computed(
|
||||
() => providerDisplayName(normalizedProvider.value) ?? (props.provider?.trim() || '未知平台'),
|
||||
)
|
||||
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
|
||||
|
||||
const authenticationLabel = computed(() => {
|
||||
if (normalizedAuthenticationKind.value === 'agent_identity')
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
|
||||
const RGBA_COLOR_PATTERN
|
||||
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
|
||||
|
||||
interface HsvColor {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
a: number
|
||||
}
|
||||
|
||||
interface RgbaColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
|
||||
export function hexToHsv(value: string): HsvColor | null {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
|
||||
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
|
||||
const maximum = Math.max(red, green, blue)
|
||||
const minimum = Math.min(red, green, blue)
|
||||
const delta = maximum - minimum
|
||||
let hue = 0
|
||||
|
||||
if (delta > 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6)
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2)
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4)
|
||||
}
|
||||
|
||||
return {
|
||||
h: hue < 0 ? hue + 360 : hue,
|
||||
s: maximum === 0 ? 0 : (delta / maximum) * 100,
|
||||
v: maximum * 100,
|
||||
a: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
export function hsvToHex(color: HsvColor) {
|
||||
const hue = ((color.h % 360) + 360) % 360
|
||||
const saturation = clamp(color.s, 0, 100) / 100
|
||||
const brightness = clamp(color.v, 0, 100) / 100
|
||||
const chroma = brightness * saturation
|
||||
const section = hue / 60
|
||||
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
|
||||
const offset = brightness - chroma
|
||||
const [red, green, blue] = section < 1
|
||||
? [chroma, intermediate, 0]
|
||||
: section < 2
|
||||
? [intermediate, chroma, 0]
|
||||
: section < 3
|
||||
? [0, chroma, intermediate]
|
||||
: section < 4
|
||||
? [0, intermediate, chroma]
|
||||
: section < 5
|
||||
? [intermediate, 0, chroma]
|
||||
: [chroma, 0, intermediate]
|
||||
|
||||
const rgb = [red, green, blue]
|
||||
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
|
||||
return `#${rgb}${alpha}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function parseRgbaColor(value: string): RgbaColor | null {
|
||||
const match = RGBA_COLOR_PATTERN.exec(value.trim())
|
||||
if (!match)
|
||||
return null
|
||||
|
||||
const red = Number(match[1])
|
||||
const green = Number(match[2])
|
||||
const blue = Number(match[3])
|
||||
const alpha = Number(match[4])
|
||||
if ([red, green, blue].some(channel => channel > 255))
|
||||
return null
|
||||
|
||||
return { r: red, g: green, b: blue, a: alpha }
|
||||
}
|
||||
|
||||
export function rgbaToHexColor(color: RgbaColor) {
|
||||
return `#${[color.r, color.g, color.b, color.a * 255]
|
||||
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function formatRgbaColor(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16)
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16)
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16)
|
||||
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(3))
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
+10
-13
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import BaseButton from '../BaseButton.vue'
|
||||
import BaseInput from '../BaseInput.vue'
|
||||
import BasePopover from '../BasePopover.vue'
|
||||
import {
|
||||
formatRgbaColor,
|
||||
hexToHsv,
|
||||
hsvToHex,
|
||||
normalizeHexColor,
|
||||
parseRgbaColor,
|
||||
rgbaToHexColor,
|
||||
} from '@/utils/color'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseInput from './BaseInput.vue'
|
||||
import BasePopover from './BasePopover.vue'
|
||||
} from './color'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,7 +39,7 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
|
||||
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
|
||||
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
|
||||
|
||||
const normalizedModel = computed(() => normalizeHexColor(model.value) ?? '#60A5FA80')
|
||||
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
|
||||
const draftColor = computed(() => hsvToHex({
|
||||
h: hue.value,
|
||||
s: saturation.value,
|
||||
@@ -201,18 +202,18 @@ function updateFromInput() {
|
||||
}
|
||||
|
||||
function choosePreset(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (normalized)
|
||||
resetDraft(normalized)
|
||||
}
|
||||
|
||||
function isPresetSelected(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
|
||||
}
|
||||
|
||||
function presetCheckClass(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return 'text-white'
|
||||
|
||||
@@ -233,10 +234,6 @@ function confirm() {
|
||||
model.value = rgbaToHexColor(rgba)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import BaseModal from './BaseModal/index.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
|
||||
let bodyScrollLocked: ReturnType<typeof useScrollLock> | undefined
|
||||
let lockCount = 0
|
||||
|
||||
function getBodyScrollLock() {
|
||||
if (typeof document === 'undefined')
|
||||
return undefined
|
||||
|
||||
bodyScrollLocked ??= useScrollLock(document.body)
|
||||
return bodyScrollLocked
|
||||
}
|
||||
|
||||
export function lockBodyScroll() {
|
||||
const scrollLock = getBodyScrollLock()
|
||||
if (!scrollLock)
|
||||
return
|
||||
|
||||
lockCount += 1
|
||||
if (lockCount === 1)
|
||||
scrollLock.value = true
|
||||
}
|
||||
|
||||
export function unlockBodyScroll() {
|
||||
if (!bodyScrollLocked)
|
||||
return
|
||||
|
||||
lockCount = Math.max(0, lockCount - 1)
|
||||
if (lockCount === 0)
|
||||
bodyScrollLocked.value = false
|
||||
}
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
import BaseScrollbar from './BaseScrollbar.vue'
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
@@ -1,14 +1,57 @@
|
||||
// ECharts tooltip 回调参数的通用安全取值:参数形状不受控(数组/对象混合),
|
||||
// 统一在此收敛类型判断。
|
||||
import { isRecord } from '@/utils/object'
|
||||
|
||||
interface ChartTooltipTheme {
|
||||
surface: string
|
||||
textPrimary: string
|
||||
pointer: string
|
||||
}
|
||||
|
||||
interface ChartTooltipStyleOptions {
|
||||
axisPointer?: boolean
|
||||
confine?: boolean
|
||||
padding?: [number, number]
|
||||
fontFamily?: string
|
||||
fontWeight?: number
|
||||
}
|
||||
|
||||
export function chartTooltipStyle(
|
||||
theme: ChartTooltipTheme,
|
||||
options: ChartTooltipStyleOptions = {},
|
||||
) {
|
||||
const base = {
|
||||
confine: options.confine,
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: options.padding ?? [10, 14],
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 12,
|
||||
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
|
||||
fontWeight: options.fontWeight ?? 650,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
}
|
||||
|
||||
if (!options.axisPointer)
|
||||
return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
axisPointer: {
|
||||
type: 'line' as const,
|
||||
lineStyle: { color: theme.pointer, type: 'dashed' as const, width: 1 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tooltipRows(params: unknown): Record<string, unknown>[] {
|
||||
const values = Array.isArray(params) ? params : [params]
|
||||
return values.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
|
||||
)
|
||||
return values.filter(isRecord)
|
||||
}
|
||||
|
||||
export function tooltipIndex(source: unknown) {
|
||||
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
|
||||
if (!isRecord(source))
|
||||
return -1
|
||||
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
type AsyncActionErrorText = string | false | ((error: unknown) => string | false)
|
||||
@@ -13,26 +13,13 @@ interface AsyncActionRunOptions {
|
||||
rethrow?: boolean
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
return typeof message === 'string' ? message : ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) {
|
||||
const fallback = typeof errorText === 'function' ? errorText(error) : errorText
|
||||
if (fallback === false) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return errorMessage(error) || fallback || ''
|
||||
return errorMessage(error, '') || fallback || ''
|
||||
}
|
||||
|
||||
export function useAsyncAction() {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useThemeColor } from '@/composables/useThemeColor'
|
||||
|
||||
export function useChartPalette() {
|
||||
const color = useThemeColor()
|
||||
const palette = computed(() => ({
|
||||
textPrimary: color('--cp-text-primary', '#0E1726'),
|
||||
textSecondary: color('--cp-text-secondary', '#64748B'),
|
||||
textMuted: color('--cp-text-muted', '#94A3B8'),
|
||||
surface: color('--cp-bg-surface', '#FFFFFF'),
|
||||
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
|
||||
grid: color('--cp-bg-muted', '#F1F5F9'),
|
||||
divider: color('--cp-divider-subtle', '#E2E8F0'),
|
||||
border: color('--cp-default-border', '#E2E8F0'),
|
||||
pointer: color('--cp-default-border-hover', '#CBD5E1'),
|
||||
info: color('--cp-info', '#2563EB'),
|
||||
success: color('--cp-success', '#10B981'),
|
||||
warning: color('--cp-warning', '#F59E0B'),
|
||||
danger: color('--cp-danger', '#EF4444'),
|
||||
normal: color('--cp-normal', '#0F9F9A'),
|
||||
reasoning: color('--cp-reasoning', '#8B5CF6'),
|
||||
}))
|
||||
|
||||
return { color, palette }
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
import { readCssVariable } from '@/utils/css'
|
||||
|
||||
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
|
||||
// 引用该函数的 computed 会重算并读到新变量值。
|
||||
@@ -12,3 +11,9 @@ export function useThemeColor() {
|
||||
return readCssVariable(name, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
function readCssVariable(name: string, fallback: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return fallback
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createSharedComposable, useNow } from '@vueuse/core'
|
||||
|
||||
export const useUiClock = createSharedComposable(() =>
|
||||
useNow({ interval: 30_000 }),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { ExternalLink } from '@lucide/vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -179,10 +180,6 @@ function applyPosition(position: StoredPosition) {
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
+45
-175
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemUpdateDetail } from '@/api'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
XCircle,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -15,12 +14,17 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import { formatTime } from '@/utils/date'
|
||||
import { renderReleaseNotes } from './markdown'
|
||||
import {
|
||||
resolveSystemUpdateLogClasses,
|
||||
resolveSystemUpdatePresentation,
|
||||
} from './presenter'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
@@ -46,177 +50,39 @@ const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
|
||||
|
||||
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
|
||||
const updateConfirmOpen = shallowRef(false)
|
||||
const updateConfirmInfo = shallowRef<any>(null)
|
||||
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
|
||||
const updateConfirmPreviousTarget = shallowRef('')
|
||||
const preparingUpdate = shallowRef(false)
|
||||
|
||||
const statusView = computed(() => {
|
||||
if (restarting.value) {
|
||||
return {
|
||||
label: '重启中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updating.value) {
|
||||
return {
|
||||
label: '更新中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updateError.value || updateInfo.value?.warning) {
|
||||
return {
|
||||
label: '异常',
|
||||
icon: XCircle,
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
iconClass: 'text-cp-danger',
|
||||
}
|
||||
}
|
||||
if (updateSuccess.value) {
|
||||
return {
|
||||
label: '已更新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (hasUpdate.value) {
|
||||
return {
|
||||
label: '有新版本',
|
||||
icon: ArrowUpCircle,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (updateInfo.value) {
|
||||
return {
|
||||
label: '已是最新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-muted text-cp-secondary',
|
||||
iconClass: 'text-cp-muted-text',
|
||||
}
|
||||
})
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
|
||||
title: version.value?.version,
|
||||
},
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新版本',
|
||||
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
|
||||
title: updateInfo.value?.latestVersion,
|
||||
releaseUrl: updateInfo.value?.releaseUrl,
|
||||
},
|
||||
{
|
||||
key: 'build',
|
||||
label: '构建',
|
||||
value: displayValue(updateInfo.value?.buildTypeLabel),
|
||||
title: updateInfo.value?.buildType,
|
||||
},
|
||||
{
|
||||
key: 'deployment',
|
||||
label: '部署',
|
||||
value: displayValue(version.value?.deploymentModeLabel),
|
||||
title: version.value?.deploymentMode,
|
||||
},
|
||||
])
|
||||
const presentation = computed(() => resolveSystemUpdatePresentation({
|
||||
version: version.value,
|
||||
updateInfo: updateInfo.value,
|
||||
loading: loading.value,
|
||||
restarting: restarting.value,
|
||||
updating: updating.value,
|
||||
updateError: updateError.value,
|
||||
updateSuccess: updateSuccess.value,
|
||||
hasUpdate: hasUpdate.value,
|
||||
updateStreaming: updateStreaming.value,
|
||||
updateStreamError: updateStreamError.value,
|
||||
previousTargetVersion: updateConfirmPreviousTarget.value,
|
||||
confirmedTargetVersion: updateConfirmInfo.value?.latestVersion ?? null,
|
||||
}))
|
||||
|
||||
const updateLogRows = computed(() =>
|
||||
updateLogs.value.map(item => ({
|
||||
...item,
|
||||
time: formatLogTime(item.at),
|
||||
time: formatTime(item.at, '--:--:--'),
|
||||
classes: resolveSystemUpdateLogClasses(item.level),
|
||||
})),
|
||||
)
|
||||
|
||||
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
|
||||
const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
)
|
||||
|
||||
const updateConfirmRows = computed(() => [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: version.value?.version ? `v${version.value.version}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'previous',
|
||||
label: '已显示目标',
|
||||
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '远端最新目标',
|
||||
value: updateConfirmInfo.value?.latestVersion
|
||||
? `v${updateConfirmInfo.value.latestVersion}`
|
||||
: '-',
|
||||
},
|
||||
])
|
||||
|
||||
const streamStatusLabel = computed(() => {
|
||||
if (updateStreaming.value)
|
||||
return '实时'
|
||||
if (updateStreamError.value)
|
||||
return '断开'
|
||||
return '待连接'
|
||||
})
|
||||
|
||||
const restartButtonLabel = computed(() => {
|
||||
return restarting.value ? '重启中' : '立即重启'
|
||||
})
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
return value ? String(value) : '-'
|
||||
}
|
||||
|
||||
function formatLogTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '--:--:--'
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function logMarkerClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-info'
|
||||
}
|
||||
|
||||
function logTextClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-primary'
|
||||
}
|
||||
|
||||
function normalizeUpdateVersion(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
async function scrollUpdateLogsToBottom() {
|
||||
await nextTick()
|
||||
await updateLogScrollbar.value?.scrollToBottom()
|
||||
@@ -242,7 +108,7 @@ async function handleUpdateRequest() {
|
||||
if (preparingUpdate.value || updating.value)
|
||||
return
|
||||
|
||||
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
|
||||
const previousTargetVersion = normalizeSystemVersion(updateInfo.value?.latestVersion)
|
||||
preparingUpdate.value = true
|
||||
try {
|
||||
const data = await checkUpdates(true)
|
||||
@@ -250,7 +116,7 @@ async function handleUpdateRequest() {
|
||||
toast.success('当前已是最新版本')
|
||||
return
|
||||
}
|
||||
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
|
||||
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
|
||||
if (!remoteTargetVersion) {
|
||||
toast.error('远端目标版本为空')
|
||||
return
|
||||
@@ -284,7 +150,7 @@ async function runConfirmedUpdate(targetVersion: string) {
|
||||
}
|
||||
|
||||
async function handleConfirmUpdate() {
|
||||
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
|
||||
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
|
||||
if (!targetVersion)
|
||||
return
|
||||
|
||||
@@ -344,16 +210,20 @@ watch(
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
|
||||
:class="statusView.badge"
|
||||
:class="presentation.status.badge"
|
||||
>
|
||||
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
|
||||
{{ statusView.label }}
|
||||
<component
|
||||
:is="presentation.status.icon"
|
||||
class="size-3.5"
|
||||
:class="presentation.status.iconClass"
|
||||
/>
|
||||
{{ presentation.status.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5 sm:grid-cols-4">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
v-for="item in presentation.summaryItems"
|
||||
:key="item.key"
|
||||
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
|
||||
>
|
||||
@@ -398,7 +268,7 @@ watch(
|
||||
发布说明
|
||||
</p>
|
||||
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
{{ presentation.releaseVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px">
|
||||
@@ -421,13 +291,13 @@ watch(
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
|
||||
:title="updateStreamError || streamStatusLabel"
|
||||
:title="updateStreamError || presentation.streamStatusLabel"
|
||||
>
|
||||
<i
|
||||
class="size-1.5 rounded-full"
|
||||
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
|
||||
/>
|
||||
{{ streamStatusLabel }}
|
||||
{{ presentation.streamStatusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -445,10 +315,10 @@ watch(
|
||||
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
|
||||
<Circle
|
||||
class="mt-1 size-2.5"
|
||||
:class="logMarkerClass(log.level)"
|
||||
:class="log.classes.marker"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
|
||||
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
|
||||
{{ log.message }}
|
||||
</p>
|
||||
@@ -486,7 +356,7 @@ watch(
|
||||
<template #icon>
|
||||
<Power class="size-4" />
|
||||
</template>
|
||||
{{ restartButtonLabel }}
|
||||
{{ presentation.restartButtonLabel }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
@@ -515,7 +385,7 @@ watch(
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
|
||||
<div
|
||||
v-for="item in updateConfirmRows"
|
||||
v-for="item in presentation.confirmRows"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
+1
-1
@@ -7,7 +7,7 @@ const marked = new Marked({
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
export function renderMarkdown(source?: string | null) {
|
||||
export function renderReleaseNotes(source?: string | null) {
|
||||
const value = source?.trim()
|
||||
if (!value)
|
||||
return ''
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SystemUpdateDetail, SystemVersion } from '@/api'
|
||||
import { ArrowUpCircle, CheckCircle2, RefreshCw, XCircle } from '@lucide/vue'
|
||||
|
||||
interface SystemUpdatePresentationInput {
|
||||
version: SystemVersion | null
|
||||
updateInfo: SystemUpdateDetail | null
|
||||
loading: boolean
|
||||
restarting: boolean
|
||||
updating: boolean
|
||||
updateError: string
|
||||
updateSuccess: boolean
|
||||
hasUpdate: boolean
|
||||
updateStreaming: boolean
|
||||
updateStreamError: string
|
||||
previousTargetVersion: string
|
||||
confirmedTargetVersion: string | null
|
||||
}
|
||||
|
||||
export function resolveSystemUpdatePresentation(input: SystemUpdatePresentationInput) {
|
||||
return {
|
||||
status: resolveStatus(input),
|
||||
summaryItems: resolveSummaryItems(input),
|
||||
confirmRows: resolveConfirmRows(input),
|
||||
releaseVersion: displayValue(input.updateInfo?.latestVersion),
|
||||
streamStatusLabel: input.updateStreaming
|
||||
? '实时'
|
||||
: input.updateStreamError ? '断开' : '待连接',
|
||||
restartButtonLabel: input.restarting ? '重启中' : '立即重启',
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSystemUpdateLogClasses(level: string) {
|
||||
switch (level) {
|
||||
case 'success':
|
||||
return { marker: 'text-cp-success', text: 'text-cp-success' }
|
||||
case 'warning':
|
||||
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
|
||||
case 'error':
|
||||
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
|
||||
default:
|
||||
return { marker: 'text-cp-info', text: 'text-cp-primary' }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatus(input: SystemUpdatePresentationInput) {
|
||||
if (input.restarting || input.updating) {
|
||||
return {
|
||||
label: input.restarting ? '重启中' : '更新中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (input.updateError || input.updateInfo?.warning) {
|
||||
return {
|
||||
label: '异常',
|
||||
icon: XCircle,
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
iconClass: 'text-cp-danger',
|
||||
}
|
||||
}
|
||||
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
|
||||
return {
|
||||
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有新版本' : '已是最新',
|
||||
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-muted text-cp-secondary',
|
||||
iconClass: 'text-cp-muted-text',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSummaryItems(input: SystemUpdatePresentationInput) {
|
||||
return [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: input.loading ? '...' : versionLabel(input.version?.version),
|
||||
title: input.version?.version,
|
||||
},
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新版本',
|
||||
value: versionLabel(input.updateInfo?.latestVersion),
|
||||
title: input.updateInfo?.latestVersion,
|
||||
releaseUrl: input.updateInfo?.releaseUrl,
|
||||
},
|
||||
{
|
||||
key: 'build',
|
||||
label: '构建',
|
||||
value: displayValue(input.updateInfo?.buildTypeLabel),
|
||||
title: input.updateInfo?.buildType,
|
||||
},
|
||||
{
|
||||
key: 'deployment',
|
||||
label: '部署',
|
||||
value: displayValue(input.version?.deploymentModeLabel),
|
||||
title: input.version?.deploymentMode,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function resolveConfirmRows(input: SystemUpdatePresentationInput) {
|
||||
return [
|
||||
{ key: 'current', label: '当前版本', value: versionLabel(input.version?.version) },
|
||||
{ key: 'previous', label: '已显示目标', value: versionLabel(input.previousTargetVersion) },
|
||||
{ key: 'target', label: '远端最新目标', value: versionLabel(input.confirmedTargetVersion) },
|
||||
]
|
||||
}
|
||||
|
||||
function versionLabel(value?: string | null) {
|
||||
return value ? `v${value}` : '-'
|
||||
}
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
return value ? String(value) : '-'
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useUiStore } from '@/stores/modules/ui'
|
||||
import AppAboutModal from './components/AppAboutModal.vue'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
import SystemUpdateModal from './components/SystemUpdateModal.vue'
|
||||
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const systemUpdateStore = useSystemUpdateStore()
|
||||
|
||||
@@ -242,7 +242,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion })
|
||||
updateSuccess.value = true
|
||||
needRestart.value = result.needRestart
|
||||
restartTargetVersion.value = result.needRestart ? normalizeVersion(result.targetVersion) : ''
|
||||
restartTargetVersion.value = result.needRestart ? normalizeSystemVersion(result.targetVersion) : ''
|
||||
updateInfo.value = {
|
||||
...currentInfo,
|
||||
latestVersion: result.targetVersion,
|
||||
@@ -271,7 +271,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const readyVersion = await getSystemVersion(restartProbeTimeoutMs)
|
||||
if (normalizeVersion(readyVersion.version) === expectedVersion) {
|
||||
if (normalizeSystemVersion(readyVersion.version) === expectedVersion) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
@@ -348,7 +348,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function normalizeVersion(value: unknown) {
|
||||
export function normalizeSystemVersion(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { delay } from 'es-toolkit'
|
||||
|
||||
export function errorMessage(error: unknown, fallback = '请求失败') {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: error && typeof error === 'object' && 'message' in error
|
||||
? (error as { message?: unknown }).message
|
||||
: undefined
|
||||
return typeof message === 'string' && message ? message : fallback
|
||||
}
|
||||
|
||||
export async function withMinimumDuration<T>(
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
|
||||
const bodyScrollLocked = useScrollLock(document.body)
|
||||
let lockCount = 0
|
||||
|
||||
export function lockBodyScroll() {
|
||||
lockCount += 1
|
||||
if (lockCount === 1)
|
||||
bodyScrollLocked.value = true
|
||||
}
|
||||
|
||||
export function unlockBodyScroll() {
|
||||
lockCount = Math.max(0, lockCount - 1)
|
||||
if (lockCount === 0)
|
||||
bodyScrollLocked.value = false
|
||||
}
|
||||
+3
-114
@@ -1,117 +1,6 @@
|
||||
const HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
|
||||
const RGBA_COLOR_PATTERN
|
||||
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
|
||||
const RGBA_HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
|
||||
|
||||
export interface HsvColor {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
a: number
|
||||
}
|
||||
|
||||
export interface RgbaColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
|
||||
export function normalizeHexColor(value: string) {
|
||||
export function normalizeRgbaHexColor(value: string) {
|
||||
const normalized = value.trim().toUpperCase()
|
||||
return HEX_COLOR_PATTERN.test(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
export function hexToHsv(value: string): HsvColor | null {
|
||||
const normalized = normalizeHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
|
||||
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
|
||||
const maximum = Math.max(red, green, blue)
|
||||
const minimum = Math.min(red, green, blue)
|
||||
const delta = maximum - minimum
|
||||
let hue = 0
|
||||
|
||||
if (delta > 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6)
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2)
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4)
|
||||
}
|
||||
|
||||
return {
|
||||
h: hue < 0 ? hue + 360 : hue,
|
||||
s: maximum === 0 ? 0 : (delta / maximum) * 100,
|
||||
v: maximum * 100,
|
||||
a: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
export function hsvToHex(color: HsvColor) {
|
||||
const hue = ((color.h % 360) + 360) % 360
|
||||
const saturation = clamp(color.s, 0, 100) / 100
|
||||
const brightness = clamp(color.v, 0, 100) / 100
|
||||
const chroma = brightness * saturation
|
||||
const section = hue / 60
|
||||
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
|
||||
const offset = brightness - chroma
|
||||
const [red, green, blue] = section < 1
|
||||
? [chroma, intermediate, 0]
|
||||
: section < 2
|
||||
? [intermediate, chroma, 0]
|
||||
: section < 3
|
||||
? [0, chroma, intermediate]
|
||||
: section < 4
|
||||
? [0, intermediate, chroma]
|
||||
: section < 5
|
||||
? [intermediate, 0, chroma]
|
||||
: [chroma, 0, intermediate]
|
||||
|
||||
const rgb = [red, green, blue]
|
||||
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
|
||||
return `#${rgb}${alpha}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function parseRgbaColor(value: string): RgbaColor | null {
|
||||
const match = RGBA_COLOR_PATTERN.exec(value.trim())
|
||||
if (!match)
|
||||
return null
|
||||
|
||||
const red = Number(match[1])
|
||||
const green = Number(match[2])
|
||||
const blue = Number(match[3])
|
||||
const alpha = Number(match[4])
|
||||
if ([red, green, blue].some(channel => channel > 255))
|
||||
return null
|
||||
|
||||
return { r: red, g: green, b: blue, a: alpha }
|
||||
}
|
||||
|
||||
export function rgbaToHexColor(color: RgbaColor) {
|
||||
return `#${[color.r, color.g, color.b, color.a * 255]
|
||||
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function formatRgbaColor(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16)
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16)
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16)
|
||||
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(2))
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
return RGBA_HEX_COLOR_PATTERN.test(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// @env browser
|
||||
|
||||
export function readCssVariable(name: string, fallback: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return fallback
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
||||
}
|
||||
@@ -4,19 +4,26 @@ import dayjs from 'dayjs'
|
||||
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
|
||||
const TIME_FORMAT = 'HH:mm:ss'
|
||||
|
||||
export function formatDateTime(value: ConfigType = new Date()): string {
|
||||
return dayjs(value).format(DATE_TIME_FORMAT)
|
||||
export function formatDateTime(value: ConfigType = new Date(), fallback = '—'): string {
|
||||
const timestamp = normalizedDate(value)
|
||||
return timestamp.isValid() ? timestamp.format(DATE_TIME_FORMAT) : fallback
|
||||
}
|
||||
|
||||
export function formatTime(value: ConfigType = new Date()): string {
|
||||
return dayjs(value).format(TIME_FORMAT)
|
||||
export function formatTime(value: ConfigType = new Date(), fallback = '—'): string {
|
||||
const timestamp = normalizedDate(value)
|
||||
return timestamp.isValid() ? timestamp.format(TIME_FORMAT) : fallback
|
||||
}
|
||||
|
||||
export function parseTimestamp(value: ConfigType): number | null {
|
||||
const timestamp = normalizedDate(value)
|
||||
return timestamp.isValid() ? timestamp.valueOf() : null
|
||||
}
|
||||
|
||||
export function formatRelativeTime(
|
||||
value: ConfigType,
|
||||
now: ConfigType = new Date(),
|
||||
): string {
|
||||
const timestamp = dayjs(value)
|
||||
const timestamp = normalizedDate(value)
|
||||
if (!timestamp.isValid())
|
||||
return '—'
|
||||
|
||||
@@ -34,3 +41,7 @@ export function formatRelativeTime(
|
||||
|
||||
return `${Math.floor(elapsedHours / 24)} 天前`
|
||||
}
|
||||
|
||||
function normalizedDate(value: ConfigType) {
|
||||
return dayjs(typeof value === 'string' ? value.replace(' ', 'T') : value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const integerFormatter = new Intl.NumberFormat('zh-CN')
|
||||
|
||||
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const metricUnits = [
|
||||
['P', 1_000_000_000_000_000],
|
||||
['T', 1_000_000_000_000],
|
||||
['B', 1_000_000_000],
|
||||
['M', 1_000_000],
|
||||
['K', 1_000],
|
||||
] as const
|
||||
|
||||
export function formatInteger(value: number) {
|
||||
return integerFormatter.format(value)
|
||||
}
|
||||
|
||||
export function formatLocalizedCompactNumber(value: number) {
|
||||
return localizedCompactFormatter.format(Number.isFinite(value) ? value : 0)
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number) {
|
||||
const normalized = Math.max(0, Math.round(value))
|
||||
if (normalized < 1_000)
|
||||
return formatInteger(normalized)
|
||||
|
||||
for (const [unit, threshold] of metricUnits) {
|
||||
if (normalized < threshold)
|
||||
continue
|
||||
|
||||
const scaled = normalized / threshold
|
||||
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
|
||||
return `${rounded.replace(/\.?0+$/, '')}${unit}`
|
||||
}
|
||||
|
||||
return formatInteger(normalized)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
// 平台标识到展示名的唯一映射;未知平台返回 undefined,由调用方决定兜底文案。
|
||||
export function providerDisplayName(value?: string | null): 'OpenAI' | 'xAI' | undefined {
|
||||
if (value === 'openai')
|
||||
return 'OpenAI'
|
||||
if (value === 'xai')
|
||||
return 'xAI'
|
||||
return undefined
|
||||
export const PROVIDER_DISPLAY_NAMES = {
|
||||
openai: 'OpenAI',
|
||||
xai: 'xAI',
|
||||
} as const
|
||||
|
||||
export type SupportedProvider = keyof typeof PROVIDER_DISPLAY_NAMES
|
||||
|
||||
export function isSupportedProvider(value: unknown): value is SupportedProvider {
|
||||
return typeof value === 'string' && value in PROVIDER_DISPLAY_NAMES
|
||||
}
|
||||
|
||||
export function providerDisplayName(value?: string | null) {
|
||||
return isSupportedProvider(value) ? PROVIDER_DISPLAY_NAMES[value] : undefined
|
||||
}
|
||||
|
||||
export function formatProviderLabel(value?: string | null, fallback = '—') {
|
||||
const normalized = value?.trim()
|
||||
return providerDisplayName(normalized?.toLowerCase()) ?? (normalized || fallback)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export const DEFAULT_ACCOUNT_GROUP_COLOR = '#60A5FA80'
|
||||
|
||||
export const ACCOUNT_GROUP_COLOR_PRESETS = [
|
||||
'#60A5FA80',
|
||||
'#22D3EE80',
|
||||
'#2DD4BF80',
|
||||
'#4ADE8080',
|
||||
'#FBBF2480',
|
||||
'#FB718580',
|
||||
'#A78BFA80',
|
||||
'#94A3B880',
|
||||
] as const
|
||||
@@ -4,13 +4,13 @@ import type { AccountGroup } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseColorPicker from '@/components/base/BaseColorPicker.vue'
|
||||
import BaseColorPicker from '@/components/base/BaseColorPicker/index.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../colors'
|
||||
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../constants'
|
||||
|
||||
const props = defineProps<{
|
||||
group: AccountGroup | null
|
||||
|
||||
@@ -16,9 +16,9 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { useIdSet } from '@/composables/useIdSet'
|
||||
import { usePagedQuery } from '@/composables/usePagedQuery'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { normalizeHexColor } from '@/utils/color'
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../colors'
|
||||
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../constants'
|
||||
|
||||
export interface AccountGroupFormValue {
|
||||
name: string
|
||||
@@ -124,7 +124,7 @@ export function useAccountGroups() {
|
||||
toast.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
const color = normalizeHexColor(form.value.color)
|
||||
const color = normalizeRgbaHexColor(form.value.color)
|
||||
if (!color) {
|
||||
toast.warning('请选择有效的分组颜色')
|
||||
return
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { AccountGroup } from '@/api'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
|
||||
export const DEFAULT_ACCOUNT_GROUP_COLOR = '#60A5FA80'
|
||||
|
||||
export const ACCOUNT_GROUP_COLOR_PRESETS = [
|
||||
'#60A5FA80',
|
||||
'#22D3EE80',
|
||||
'#2DD4BF80',
|
||||
'#4ADE8080',
|
||||
'#FBBF2480',
|
||||
'#FB718580',
|
||||
'#A78BFA80',
|
||||
'#94A3B880',
|
||||
] as const
|
||||
|
||||
export const accountGroupColumns = defineTableColumns<AccountGroup>([
|
||||
{ key: 'selection', kind: 'selection' },
|
||||
{ key: 'identity', label: '分组', kind: 'identity' },
|
||||
|
||||
@@ -5,7 +5,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { useAccountConnectionTest } from '../composables/useAccountConnecti
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import AccountIdentityCell from './AccountIdentityCell.vue'
|
||||
import AccountStatusBadge from './AccountStatusBadge.vue'
|
||||
import AccountStatusBadge from './AccountStatusBadge/index.vue'
|
||||
|
||||
type ConnectionTest = ReturnType<typeof useAccountConnectionTest>
|
||||
|
||||
@@ -62,6 +62,7 @@ function connectionLogClass(tone: string) {
|
||||
:status="account.status"
|
||||
:error-reason="account.errorReason"
|
||||
:error-message="account.errorMessage"
|
||||
:next-refresh-at="account.nextRefreshAt"
|
||||
variant="pill"
|
||||
/>
|
||||
</section>
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { LayoutGrid } from '@lucide/vue'
|
||||
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -23,12 +24,12 @@ const providers = [
|
||||
},
|
||||
{
|
||||
value: 'openai' as const,
|
||||
label: 'OpenAI',
|
||||
label: PROVIDER_DISPLAY_NAMES.openai,
|
||||
icon: Openai,
|
||||
},
|
||||
{
|
||||
value: 'xai' as const,
|
||||
label: 'xAI',
|
||||
label: PROVIDER_DISPLAY_NAMES.xai,
|
||||
icon: Xai,
|
||||
},
|
||||
]
|
||||
+38
-111
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
|
||||
import type { AccountRow } from '../constants'
|
||||
import type { AccountRow } from '../../constants'
|
||||
import type { AccountCreateForm } from './model'
|
||||
import type { AccountCreateProvider } from './presenter'
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
|
||||
|
||||
@@ -10,16 +11,13 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import { useCopyText } from '@/composables/useCopyText'
|
||||
import { accountProviderModeOptions } from '../composables/useAccountOnboarding'
|
||||
import AccountProviderChooser from './AccountProviderChooser.vue'
|
||||
|
||||
type AccountOnboarding = ReturnType<typeof useAccountOnboarding>
|
||||
type CreateForm = AccountOnboarding['createForm']['value']
|
||||
import { resolveAccountCreatePresentation } from './presenter'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -41,7 +39,7 @@ const emit = defineEmits<{
|
||||
generateOauth: []
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const form = defineModel<CreateForm>('form', { required: true })
|
||||
const form = defineModel<AccountCreateForm>('form', { required: true })
|
||||
const copyWithToast = useCopyText()
|
||||
|
||||
const fileError = ref('')
|
||||
@@ -51,9 +49,13 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
|
||||
reset: true,
|
||||
})
|
||||
|
||||
const modeOptions = computed(() => accountProviderModeOptions(form.value.provider))
|
||||
const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider))
|
||||
const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value)
|
||||
const view = computed(() => resolveAccountCreatePresentation({
|
||||
form: form.value,
|
||||
account: props.account,
|
||||
saving: props.saving,
|
||||
oauthLoading: props.oauthLoading,
|
||||
reauthorizing: props.reauthorizing,
|
||||
}))
|
||||
|
||||
const provider = computed({
|
||||
get: () => form.value.provider,
|
||||
@@ -90,82 +92,7 @@ const oauthCallback = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
|
||||
const isXai = computed(() => form.value.provider === 'xai')
|
||||
const isBatch = computed(() => form.value.provider === 'batch')
|
||||
const importFileLabel = computed(() => {
|
||||
if (isBatch.value)
|
||||
return '批量账号文件'
|
||||
return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件'
|
||||
})
|
||||
const importFilePlaceholder = computed(() => mode.value === 'agent_identity'
|
||||
? '粘贴 Agent 身份文件内容'
|
||||
: isBatch.value
|
||||
? '粘贴 CPR 多平台导出文件内容'
|
||||
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
|
||||
|
||||
const accountName = computed(() => {
|
||||
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (props.reauthorizing)
|
||||
return '重新授权账号'
|
||||
return isChoosingProvider.value ? '选择账号平台' : '导入账号'
|
||||
})
|
||||
|
||||
const oauthPanelTitle = computed(() => {
|
||||
if (props.reauthorizing)
|
||||
return accountName.value
|
||||
return isXai.value ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权'
|
||||
})
|
||||
|
||||
const oauthPanelDescription = computed(() => {
|
||||
return isXai.value
|
||||
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
|
||||
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址'
|
||||
})
|
||||
|
||||
const canGenerateOauth = computed(() =>
|
||||
isProviderSelected.value
|
||||
&& !props.saving
|
||||
&& !props.oauthLoading,
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!isProviderSelected.value || props.saving || props.oauthLoading)
|
||||
return false
|
||||
if (mode.value === 'oauth') {
|
||||
if (!form.value.oauthFlowId || !oauthAuthUrl.value)
|
||||
return false
|
||||
return oauthCallback.value.trim().length > 0
|
||||
}
|
||||
return importText.value.trim().length > 0
|
||||
})
|
||||
|
||||
const description = computed<string | undefined>(() => {
|
||||
if (isChoosingProvider.value)
|
||||
return undefined
|
||||
if (props.reauthorizing) {
|
||||
return isXai.value
|
||||
? '完成新的 xAI 授权并替换此账号凭据'
|
||||
: '完成新的 OpenAI 授权并替换此账号凭据'
|
||||
}
|
||||
if (isBatch.value)
|
||||
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
|
||||
if (isXai.value) {
|
||||
return mode.value === 'oauth'
|
||||
? '通过浏览器授权导入 xAI 账号'
|
||||
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
|
||||
}
|
||||
if (mode.value === 'oauth')
|
||||
return '通过浏览器授权导入 OpenAI 账号'
|
||||
if (mode.value === 'agent_identity')
|
||||
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
|
||||
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
|
||||
})
|
||||
|
||||
function selectProvider(value: 'openai' | 'xai' | 'batch') {
|
||||
function selectProvider(value: AccountCreateProvider) {
|
||||
provider.value = value
|
||||
}
|
||||
|
||||
@@ -195,30 +122,30 @@ async function copyText(value: string, successText: string) {
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
:title="modalTitle"
|
||||
:description="description"
|
||||
:tone="isChoosingProvider ? 'neutral' : 'info'"
|
||||
:size="isChoosingProvider ? 'sm' : 'md'"
|
||||
:title="view.modal.title"
|
||||
:description="view.modal.description"
|
||||
:tone="view.modal.tone"
|
||||
:size="view.modal.size"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<template #icon>
|
||||
<LayoutGrid v-if="isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<LayoutGrid v-if="view.isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="view.isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
</template>
|
||||
|
||||
<AccountProviderChooser
|
||||
v-if="isChoosingProvider"
|
||||
v-if="view.choosingProvider"
|
||||
:disabled="saving || oauthLoading"
|
||||
@select="selectProvider"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<BaseSegmented
|
||||
v-if="!reauthorizing && !isBatch"
|
||||
v-if="!reauthorizing && !view.isBatch"
|
||||
v-model="mode"
|
||||
label="账号添加方式"
|
||||
:options="modeOptions"
|
||||
:options="view.modeOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
@@ -232,10 +159,10 @@ async function copyText(value: string, successText: string) {
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[13px] font-bold text-cp-primary">
|
||||
{{ oauthPanelTitle }}
|
||||
{{ view.oauth.panelTitle }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-cp-secondary">
|
||||
{{ oauthPanelDescription }}
|
||||
{{ view.oauth.panelDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,14 +172,14 @@ async function copyText(value: string, successText: string) {
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:loading="oauthLoading"
|
||||
:disabled="!canGenerateOauth"
|
||||
:disabled="!view.canGenerateOauth"
|
||||
@click="emit('generateOauth')"
|
||||
>
|
||||
{{ reauthorizing ? '重新生成授权链接' : '生成授权链接' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<BaseForm v-if="oauthAuthUrl">
|
||||
<BaseForm v-if="view.oauth.authUrl">
|
||||
<BaseFormItem label="授权链接">
|
||||
<template #extra>
|
||||
<BaseIconButton
|
||||
@@ -261,7 +188,7 @@ async function copyText(value: string, successText: string) {
|
||||
title="复制链接"
|
||||
label="复制链接"
|
||||
:disabled="saving || oauthLoading"
|
||||
@click="copyText(oauthAuthUrl, '授权链接已复制')"
|
||||
@click="copyText(view.oauth.authUrl, '授权链接已复制')"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
@@ -272,7 +199,7 @@ async function copyText(value: string, successText: string) {
|
||||
<div class="rounded-cp-control bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 shadow-cp-input">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-emphasis text-cp-secondary"
|
||||
v-text="oauthAuthUrl"
|
||||
v-text="view.oauth.authUrl"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
@@ -280,12 +207,12 @@ async function copyText(value: string, successText: string) {
|
||||
</BaseForm>
|
||||
|
||||
<BaseForm>
|
||||
<BaseFormItem :label="isXai ? '回调地址或授权码' : '回调地址'" required>
|
||||
<BaseFormItem :label="view.oauth.callbackLabel" required>
|
||||
<BaseTextarea
|
||||
v-model="oauthCallback"
|
||||
:aria-label="isXai ? '回调地址或授权码' : '回调地址'"
|
||||
:aria-label="view.oauth.callbackLabel"
|
||||
:rows="4"
|
||||
:placeholder="isXai ? '回调地址、?code=...&state=... 或授权码' : 'http://localhost:1455/auth/callback?code=...&state=...'"
|
||||
:placeholder="view.oauth.callbackPlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
@@ -294,7 +221,7 @@ async function copyText(value: string, successText: string) {
|
||||
|
||||
<BaseForm v-else>
|
||||
<BaseFormItem
|
||||
:label="importFileLabel"
|
||||
:label="view.importFile.label"
|
||||
required
|
||||
:error="fileError || undefined"
|
||||
>
|
||||
@@ -308,26 +235,26 @@ async function copyText(value: string, successText: string) {
|
||||
</template>
|
||||
<BaseTextarea
|
||||
v-model="importText"
|
||||
:aria-label="importFileLabel"
|
||||
:aria-label="view.importFile.label"
|
||||
:rows="9"
|
||||
:placeholder="importFilePlaceholder"
|
||||
:placeholder="view.importFile.placeholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</div>
|
||||
|
||||
<template v-if="!isChoosingProvider" #footer>
|
||||
<template v-if="!view.choosingProvider" #footer>
|
||||
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
:disabled="!view.canSubmit"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }}
|
||||
{{ view.submitLabel }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface AccountCreateForm {
|
||||
provider: string
|
||||
name: string
|
||||
mode: string
|
||||
importText: string
|
||||
oauthFlowId: string
|
||||
oauthAuthUrl: string
|
||||
oauthCallback: string
|
||||
}
|
||||
|
||||
export function emptyAccountCreateForm(): AccountCreateForm {
|
||||
return {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'oauth',
|
||||
importText: '',
|
||||
oauthFlowId: '',
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AccountRow } from '../../constants'
|
||||
import type { AccountCreateForm } from './model'
|
||||
|
||||
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
|
||||
|
||||
interface AccountCreatePresentationInput {
|
||||
form: AccountCreateForm
|
||||
account: AccountRow | null
|
||||
saving: boolean
|
||||
oauthLoading: boolean
|
||||
reauthorizing: boolean
|
||||
}
|
||||
|
||||
const modeOptions = {
|
||||
openai: [
|
||||
{ label: 'OAuth', value: 'oauth' },
|
||||
{ label: '账号文件', value: 'json' },
|
||||
{ label: 'Agent 身份', value: 'agent_identity' },
|
||||
],
|
||||
xai: [
|
||||
{ label: 'OAuth', value: 'oauth' },
|
||||
{ label: '账号文件', value: 'json' },
|
||||
],
|
||||
} satisfies Record<Exclude<AccountCreateProvider, 'batch'>, Array<{ label: string, value: string }>>
|
||||
|
||||
export function resolveAccountCreatePresentation(input: AccountCreatePresentationInput) {
|
||||
const { form } = input
|
||||
const providerSelected = isAccountCreateProvider(form.provider)
|
||||
const choosingProvider = !input.reauthorizing && !providerSelected
|
||||
const isXai = form.provider === 'xai'
|
||||
const isBatch = form.provider === 'batch'
|
||||
const oauthAuthUrl = form.oauthAuthUrl || ''
|
||||
const accountName = input.account?.email
|
||||
|| input.account?.accountId
|
||||
|| input.account?.id
|
||||
|| '该账号'
|
||||
|
||||
return {
|
||||
choosingProvider,
|
||||
isXai,
|
||||
isBatch,
|
||||
modeOptions: form.provider === 'openai' || form.provider === 'xai'
|
||||
? modeOptions[form.provider]
|
||||
: [],
|
||||
modal: {
|
||||
title: input.reauthorizing
|
||||
? '重新授权账号'
|
||||
: choosingProvider ? '选择账号平台' : '导入账号',
|
||||
description: modalDescription(input, { isBatch, isXai, choosingProvider }),
|
||||
tone: choosingProvider ? 'neutral' as const : 'info' as const,
|
||||
size: choosingProvider ? 'sm' as const : 'md' as const,
|
||||
},
|
||||
oauth: {
|
||||
authUrl: oauthAuthUrl,
|
||||
panelTitle: input.reauthorizing
|
||||
? accountName
|
||||
: isXai ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权',
|
||||
panelDescription: isXai
|
||||
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
|
||||
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址',
|
||||
callbackLabel: isXai ? '回调地址或授权码' : '回调地址',
|
||||
callbackPlaceholder: isXai
|
||||
? '回调地址、?code=...&state=... 或授权码'
|
||||
: 'http://localhost:1455/auth/callback?code=...&state=...',
|
||||
},
|
||||
importFile: {
|
||||
label: isBatch
|
||||
? '批量账号文件'
|
||||
: form.mode === 'agent_identity' ? 'Agent 身份文件' : '账号文件',
|
||||
placeholder: form.mode === 'agent_identity'
|
||||
? '粘贴 Agent 身份文件内容'
|
||||
: isBatch
|
||||
? '粘贴 CPR 多平台导出文件内容'
|
||||
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容',
|
||||
},
|
||||
canGenerateOauth: providerSelected && !input.saving && !input.oauthLoading,
|
||||
canSubmit: canSubmit(input, providerSelected, oauthAuthUrl),
|
||||
submitLabel: input.reauthorizing
|
||||
? '完成重新授权'
|
||||
: form.mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入',
|
||||
}
|
||||
}
|
||||
|
||||
function modalDescription(
|
||||
input: AccountCreatePresentationInput,
|
||||
state: { isBatch: boolean, isXai: boolean, choosingProvider: boolean },
|
||||
) {
|
||||
if (state.choosingProvider)
|
||||
return undefined
|
||||
if (input.reauthorizing) {
|
||||
return state.isXai
|
||||
? '完成新的 xAI 授权并替换此账号凭据'
|
||||
: '完成新的 OpenAI 授权并替换此账号凭据'
|
||||
}
|
||||
if (state.isBatch)
|
||||
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
|
||||
if (state.isXai) {
|
||||
return input.form.mode === 'oauth'
|
||||
? '通过浏览器授权导入 xAI 账号'
|
||||
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
|
||||
}
|
||||
if (input.form.mode === 'oauth')
|
||||
return '通过浏览器授权导入 OpenAI 账号'
|
||||
if (input.form.mode === 'agent_identity')
|
||||
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
|
||||
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
|
||||
}
|
||||
|
||||
function canSubmit(
|
||||
input: AccountCreatePresentationInput,
|
||||
providerSelected: boolean,
|
||||
oauthAuthUrl: string,
|
||||
) {
|
||||
if (!providerSelected || input.saving || input.oauthLoading)
|
||||
return false
|
||||
if (input.form.mode !== 'oauth')
|
||||
return input.form.importText.trim().length > 0
|
||||
return Boolean(
|
||||
input.form.oauthFlowId
|
||||
&& oauthAuthUrl
|
||||
&& input.form.oauthCallback.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function isAccountCreateProvider(value: string): value is AccountCreateProvider {
|
||||
return value === 'openai' || value === 'xai' || value === 'batch'
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import AccountIdentityCell from './AccountIdentityCell.vue'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
||||
import { formatInteger } from '@/utils/number'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: Awaited<ReturnType<typeof getAccounts>>['summary']
|
||||
@@ -13,38 +14,34 @@ const props = defineProps<{
|
||||
const overviewItems = computed(() => [
|
||||
{
|
||||
label: '总账号',
|
||||
value: formatCount(props.summary.total),
|
||||
value: formatInteger(props.summary.total),
|
||||
caption: '账号池规模',
|
||||
tone: 'info',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: '正常账号',
|
||||
value: formatCount(props.summary.normal),
|
||||
value: formatInteger(props.summary.normal),
|
||||
caption: '可参与调度',
|
||||
tone: 'success',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: '额度受限',
|
||||
value: formatCount((props.summary.quotaExhausted ?? 0) + (props.summary.rateLimited ?? 0)),
|
||||
value: formatInteger((props.summary.quotaExhausted ?? 0) + (props.summary.rateLimited ?? 0)),
|
||||
caption: '等待额度恢复',
|
||||
tone: 'warning',
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
label: '待处理',
|
||||
value: formatCount((props.summary.disabled ?? 0) + (props.summary.error ?? 0)),
|
||||
value: formatInteger((props.summary.disabled ?? 0) + (props.summary.error ?? 0)),
|
||||
caption: '已停用 / 错误',
|
||||
tone: 'danger',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
])
|
||||
|
||||
function formatCount(value: number) {
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function overviewIconClass(tone: string) {
|
||||
if (tone === 'success') {
|
||||
return 'bg-cp-success-bg text-cp-success-text'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed } from 'vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import { orderedPanelQuotaWindows } from '../constants'
|
||||
import AccountPlanBadge from './AccountPlanBadge.vue'
|
||||
import AccountUsageWindow from './AccountUsageWindow.vue'
|
||||
import AccountUsageWindow from './AccountUsageWindow/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AccountRow } from '../constants'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { visibleSummaryQuotaWindows } from '../constants'
|
||||
import AccountUsageWindow from './AccountUsageWindow.vue'
|
||||
import AccountUsageWindow from './AccountUsageWindow/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountErrorReason, AccountStatus } from '@/api'
|
||||
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { errorReasonLabels, statusLabels, statusTones } from '../constants'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: AccountStatus
|
||||
errorReason?: AccountErrorReason | null
|
||||
errorMessage?: string | null
|
||||
rateLimitedUntil?: string | null
|
||||
variant?: 'inline' | 'pill'
|
||||
}>(),
|
||||
{
|
||||
errorReason: null,
|
||||
errorMessage: null,
|
||||
rateLimitedUntil: null,
|
||||
variant: 'inline',
|
||||
},
|
||||
)
|
||||
|
||||
const statusStyles = {
|
||||
success: {
|
||||
text: 'text-cp-success-text',
|
||||
dot: 'bg-cp-success',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
icon: 'bg-cp-success-bg text-cp-success-text',
|
||||
},
|
||||
danger: {
|
||||
text: 'text-cp-danger-text',
|
||||
dot: 'bg-cp-danger',
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
icon: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
},
|
||||
warning: {
|
||||
text: 'text-cp-warning-text',
|
||||
dot: 'bg-cp-warning',
|
||||
badge: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
icon: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
},
|
||||
info: {
|
||||
text: 'text-cp-info-text',
|
||||
dot: 'bg-cp-info',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
icon: 'bg-cp-info-bg text-cp-info-text',
|
||||
},
|
||||
normal: {
|
||||
text: 'text-cp-secondary',
|
||||
dot: 'bg-cp-muted-text',
|
||||
badge: 'bg-cp-subtle text-cp-secondary',
|
||||
icon: 'bg-cp-subtle text-cp-secondary',
|
||||
},
|
||||
} as const
|
||||
|
||||
const tone = computed(() => statusTones[props.status])
|
||||
const statusStyle = computed(() => statusStyles[tone.value])
|
||||
const label = computed(() => statusLabels[props.status])
|
||||
const reasonLabel = computed(() =>
|
||||
props.errorReason
|
||||
? errorReasonLabels[props.errorReason]
|
||||
: null,
|
||||
)
|
||||
const errorText = computed(() => props.errorMessage || null)
|
||||
const detailTitle = computed(() => reasonLabel.value || label.value)
|
||||
const isRateLimited = computed(() =>
|
||||
props.status === 'rate_limited' && Boolean(props.rateLimitedUntil),
|
||||
)
|
||||
const rateLimitedRelative = computed(() => {
|
||||
const rateLimitedUntil = props.rateLimitedUntil
|
||||
if (!rateLimitedUntil)
|
||||
return null
|
||||
|
||||
const until = new Date(rateLimitedUntil.replace(' ', 'T')).getTime()
|
||||
const diffMinutes = Math.round((until - Date.now()) / 60_000)
|
||||
if (Number.isNaN(diffMinutes) || diffMinutes < 1)
|
||||
return null
|
||||
if (diffMinutes < 60)
|
||||
return `剩余 ${diffMinutes} 分钟`
|
||||
const hours = Math.floor(diffMinutes / 60)
|
||||
const mins = diffMinutes % 60
|
||||
if (mins === 0)
|
||||
return `剩余 ${hours} 小时`
|
||||
return `剩余 ${hours} 小时 ${mins} 分`
|
||||
})
|
||||
const hasDetail = computed(() =>
|
||||
props.status === 'error' || isRateLimited.value,
|
||||
)
|
||||
|
||||
const detailDescription = computed(() => {
|
||||
if (props.status === 'rate_limited')
|
||||
return '上游暂时限制了请求频率,系统正在冷却该账号。'
|
||||
if (props.status === 'error')
|
||||
return '该账号暂不参与调度,处理凭据后可重新测试连接。'
|
||||
return '该账号当前状态需要关注。'
|
||||
})
|
||||
|
||||
const recoveryHint = computed(() => {
|
||||
if (props.status === 'rate_limited')
|
||||
return '冷却结束后,系统会自动恢复调度。'
|
||||
|
||||
switch (props.errorReason) {
|
||||
case 'account_unverified':
|
||||
case 'access_token_expired':
|
||||
case 'credential_expired':
|
||||
return '重新授权后,账号会重新参与调度。'
|
||||
case 'credential_invalid':
|
||||
return '请更新或重新导入凭据,再次测试连接。'
|
||||
case 'account_banned':
|
||||
return '请确认上游账号状态,解除限制后再启用。'
|
||||
}
|
||||
return '重新测试连接以获取最新状态。'
|
||||
})
|
||||
|
||||
const triggerLabel = computed(() =>
|
||||
`${detailTitle.value}。${detailDescription.value} 点击或聚焦查看详情。`,
|
||||
)
|
||||
|
||||
const cardIcon = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'error': return AlertTriangle
|
||||
case 'rate_limited': return Timer
|
||||
case 'quota_exhausted': return Gauge
|
||||
case 'disabled': return Power
|
||||
default: return CircleCheck
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePopover
|
||||
:disabled="!hasDetail"
|
||||
trigger="hover-click"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #trigger="{ open }">
|
||||
<component
|
||||
:is="hasDetail ? 'button' : 'span'"
|
||||
:type="hasDetail ? 'button' : undefined"
|
||||
:aria-label="hasDetail ? triggerLabel : undefined"
|
||||
:aria-expanded="hasDetail ? open : undefined"
|
||||
:aria-haspopup="hasDetail ? 'dialog' : undefined"
|
||||
class="group inline-flex min-w-0 cursor-default border-0 bg-transparent p-0 text-left outline-none"
|
||||
>
|
||||
<span
|
||||
v-if="variant === 'pill'"
|
||||
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
|
||||
:class="statusStyle.text"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
|
||||
:class="statusStyle.text"
|
||||
>
|
||||
<span aria-hidden="true" class="size-1.5 rounded-full" :class="statusStyle.dot" />
|
||||
<span>{{ label }}</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<section class="w-88 overflow-hidden rounded-cp-overlay">
|
||||
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
|
||||
:class="statusStyle.icon"
|
||||
>
|
||||
<component :is="cardIcon" class="size-4.5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
|
||||
账号状态
|
||||
</p>
|
||||
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
|
||||
{{ detailTitle }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[10px] leading-none font-heavy"
|
||||
:class="statusStyle.badge"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
|
||||
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
|
||||
{{ detailDescription }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="isRateLimited"
|
||||
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
|
||||
>
|
||||
<span class="font-heavy text-cp-tertiary">预计恢复</span>
|
||||
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
|
||||
{{ rateLimitedRelative ?? props.rateLimitedUntil }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
|
||||
建议操作
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
|
||||
{{ recoveryHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorText"
|
||||
class="overflow-hidden rounded-cp-control bg-cp-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
|
||||
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
|
||||
</div>
|
||||
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
|
||||
<div class="px-3 py-2">
|
||||
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ errorText }}</pre>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BasePopover>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountErrorReason, AccountStatus } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import { resolveAccountStatusPresentation } from './presenter'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: AccountStatus
|
||||
errorReason?: AccountErrorReason | null
|
||||
errorMessage?: string | null
|
||||
rateLimitedUntil?: string | null
|
||||
nextRefreshAt?: string | null
|
||||
variant?: 'inline' | 'pill'
|
||||
}>(),
|
||||
{
|
||||
errorReason: null,
|
||||
errorMessage: null,
|
||||
rateLimitedUntil: null,
|
||||
nextRefreshAt: null,
|
||||
variant: 'inline',
|
||||
},
|
||||
)
|
||||
|
||||
const now = useUiClock()
|
||||
const presentation = computed(() => resolveAccountStatusPresentation({
|
||||
status: props.status,
|
||||
errorReason: props.errorReason,
|
||||
errorMessage: props.errorMessage,
|
||||
rateLimitedUntil: props.rateLimitedUntil,
|
||||
nextRefreshAt: props.nextRefreshAt,
|
||||
now: now.value.getTime(),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePopover
|
||||
:disabled="!presentation.hasDetail"
|
||||
trigger="hover-click"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #trigger="{ open }">
|
||||
<component
|
||||
:is="presentation.hasDetail ? 'button' : 'span'"
|
||||
:type="presentation.hasDetail ? 'button' : undefined"
|
||||
:aria-label="presentation.hasDetail ? presentation.triggerLabel : undefined"
|
||||
:aria-expanded="presentation.hasDetail ? open : undefined"
|
||||
:aria-haspopup="presentation.hasDetail ? 'dialog' : undefined"
|
||||
class="group inline-flex min-w-0 cursor-default border-0 bg-transparent p-0 text-left outline-none"
|
||||
>
|
||||
<span
|
||||
v-if="variant === 'pill'"
|
||||
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
|
||||
:class="presentation.statusStyle.text"
|
||||
>
|
||||
{{ presentation.label }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
|
||||
:class="presentation.statusStyle.text"
|
||||
>
|
||||
<span aria-hidden="true" class="size-1.5 rounded-full" :class="presentation.statusStyle.dot" />
|
||||
<span>{{ presentation.label }}</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<section class="w-88 overflow-hidden rounded-cp-overlay">
|
||||
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
|
||||
:class="presentation.statusStyle.icon"
|
||||
>
|
||||
<component :is="presentation.icon" class="size-4.5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
|
||||
账号状态
|
||||
</p>
|
||||
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
|
||||
{{ presentation.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[10px] leading-none font-heavy"
|
||||
:class="presentation.statusStyle.badge"
|
||||
>
|
||||
{{ presentation.label }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
|
||||
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
|
||||
{{ presentation.description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="presentation.rateLimitRecovery"
|
||||
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
|
||||
>
|
||||
<span class="font-heavy text-cp-tertiary">预计恢复</span>
|
||||
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
|
||||
{{ presentation.rateLimitRecovery }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="presentation.nextRefreshDisplay"
|
||||
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
|
||||
>
|
||||
<span class="font-heavy text-cp-tertiary">下次尝试</span>
|
||||
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
|
||||
{{ presentation.nextRefreshDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
|
||||
建议操作
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
|
||||
{{ presentation.recoveryHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="presentation.errorText"
|
||||
class="overflow-hidden rounded-cp-control bg-cp-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
|
||||
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
|
||||
</div>
|
||||
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
|
||||
<div class="px-3 py-2">
|
||||
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ presentation.errorText }}</pre>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BasePopover>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { AccountErrorReason, AccountStatus } from '@/api'
|
||||
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
|
||||
|
||||
import { formatDateTime, parseTimestamp } from '@/utils/date'
|
||||
import { errorReasonLabels, statusLabels, statusTones } from '../../constants'
|
||||
|
||||
export type AccountStatusDisplayMode = AccountStatus | 'refresh_backoff'
|
||||
|
||||
type AccountStatusTone = (typeof statusTones)[AccountStatus]
|
||||
|
||||
interface AccountStatusDisplayDefinition {
|
||||
tone: AccountStatusTone
|
||||
label: string
|
||||
title?: string
|
||||
description: string
|
||||
recoveryHint: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
interface AccountStatusStyle {
|
||||
text: string
|
||||
dot: string
|
||||
badge: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface AccountStatusPresentation {
|
||||
mode: AccountStatusDisplayMode
|
||||
statusStyle: AccountStatusStyle
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
recoveryHint: string
|
||||
icon: Component
|
||||
hasDetail: boolean
|
||||
errorText: string | null
|
||||
nextRefreshDisplay: string | null
|
||||
rateLimitRecovery: string | null
|
||||
triggerLabel: string
|
||||
}
|
||||
|
||||
export interface AccountStatusPresentationInput {
|
||||
status: AccountStatus
|
||||
errorReason: AccountErrorReason | null
|
||||
errorMessage: string | null
|
||||
rateLimitedUntil: string | null
|
||||
nextRefreshAt: string | null
|
||||
now: number
|
||||
}
|
||||
|
||||
const statusStyles: Record<AccountStatusTone, AccountStatusStyle> = {
|
||||
success: {
|
||||
text: 'text-cp-success-text',
|
||||
dot: 'bg-cp-success',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
icon: 'bg-cp-success-bg text-cp-success-text',
|
||||
},
|
||||
danger: {
|
||||
text: 'text-cp-danger-text',
|
||||
dot: 'bg-cp-danger',
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
icon: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
},
|
||||
warning: {
|
||||
text: 'text-cp-warning-text',
|
||||
dot: 'bg-cp-warning',
|
||||
badge: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
icon: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
},
|
||||
info: {
|
||||
text: 'text-cp-info-text',
|
||||
dot: 'bg-cp-info',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
icon: 'bg-cp-info-bg text-cp-info-text',
|
||||
},
|
||||
normal: {
|
||||
text: 'text-cp-secondary',
|
||||
dot: 'bg-cp-muted-text',
|
||||
badge: 'bg-cp-subtle text-cp-secondary',
|
||||
icon: 'bg-cp-subtle text-cp-secondary',
|
||||
},
|
||||
}
|
||||
|
||||
const displayDefinitions: Record<AccountStatusDisplayMode, AccountStatusDisplayDefinition> = {
|
||||
refresh_backoff: {
|
||||
tone: 'warning',
|
||||
label: '退避中',
|
||||
title: 'OAuth 刷新退避',
|
||||
description: '刷新失败,系统正在等待下一次自动尝试。',
|
||||
recoveryHint: '系统会在计划时间自动重试。',
|
||||
icon: Timer,
|
||||
},
|
||||
normal: {
|
||||
tone: statusTones.normal,
|
||||
label: statusLabels.normal,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
quota_exhausted: {
|
||||
tone: statusTones.quota_exhausted,
|
||||
label: statusLabels.quota_exhausted,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
icon: Gauge,
|
||||
},
|
||||
rate_limited: {
|
||||
tone: statusTones.rate_limited,
|
||||
label: statusLabels.rate_limited,
|
||||
description: '上游暂时限制了请求频率,系统正在冷却该账号。',
|
||||
recoveryHint: '冷却结束后,系统会自动恢复调度。',
|
||||
icon: Timer,
|
||||
},
|
||||
disabled: {
|
||||
tone: statusTones.disabled,
|
||||
label: statusLabels.disabled,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
icon: Power,
|
||||
},
|
||||
error: {
|
||||
tone: statusTones.error,
|
||||
label: statusLabels.error,
|
||||
description: '该账号暂不参与调度,处理凭据后可重新测试连接。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
}
|
||||
|
||||
const errorRecoveryHints: Record<AccountErrorReason, string> = {
|
||||
account_unverified: '重新授权后,账号会重新参与调度。',
|
||||
access_token_expired: '重新授权后,账号会重新参与调度。',
|
||||
credential_expired: '重新授权后,账号会重新参与调度。',
|
||||
credential_invalid: '请更新或重新导入凭据,再次测试连接。',
|
||||
account_banned: '请确认上游账号状态,解除限制后再启用。',
|
||||
}
|
||||
|
||||
export function resolveAccountStatusPresentation(
|
||||
input: AccountStatusPresentationInput,
|
||||
): AccountStatusPresentation {
|
||||
const nextRefreshTimestamp = input.nextRefreshAt === null
|
||||
? null
|
||||
: parseTimestamp(input.nextRefreshAt)
|
||||
const isBackoff = input.status !== 'disabled'
|
||||
&& nextRefreshTimestamp !== null
|
||||
&& nextRefreshTimestamp > input.now
|
||||
const mode: AccountStatusDisplayMode = isBackoff ? 'refresh_backoff' : input.status
|
||||
const definition = displayDefinitions[mode]
|
||||
const nextRefreshDisplay = isBackoff ? formatDateTime(nextRefreshTimestamp) : null
|
||||
const reasonLabel = input.errorReason ? errorReasonLabels[input.errorReason] : null
|
||||
const title = definition.title ?? reasonLabel ?? definition.label
|
||||
const rateLimitRecovery = input.status === 'rate_limited'
|
||||
? remainingTime(input.rateLimitedUntil, input.now)
|
||||
: null
|
||||
const recoveryHint = mode !== 'refresh_backoff'
|
||||
&& mode !== 'rate_limited'
|
||||
&& input.errorReason
|
||||
? errorRecoveryHints[input.errorReason]
|
||||
: definition.recoveryHint
|
||||
const hasDetail = input.status === 'error' || rateLimitRecovery !== null || isBackoff
|
||||
const retry = nextRefreshDisplay ? ` 下次尝试:${nextRefreshDisplay}。` : ''
|
||||
const triggerLabel = `${title}。${definition.description}${retry} 点击或聚焦查看详情。`
|
||||
|
||||
return {
|
||||
mode,
|
||||
statusStyle: statusStyles[definition.tone],
|
||||
label: definition.label,
|
||||
title,
|
||||
description: definition.description,
|
||||
recoveryHint,
|
||||
icon: definition.icon,
|
||||
hasDetail,
|
||||
errorText: input.errorMessage || null,
|
||||
nextRefreshDisplay,
|
||||
rateLimitRecovery,
|
||||
triggerLabel,
|
||||
}
|
||||
}
|
||||
|
||||
function remainingTime(value: string | null, now: number) {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
const until = parseTimestamp(value)
|
||||
if (until === null)
|
||||
return value
|
||||
|
||||
const minutes = Math.round((until - now) / 60_000)
|
||||
if (minutes < 1)
|
||||
return value
|
||||
if (minutes < 60)
|
||||
return `剩余 ${minutes} 分钟`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return remainingMinutes === 0
|
||||
? `剩余 ${hours} 小时`
|
||||
: `剩余 ${hours} 小时 ${remainingMinutes} 分`
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountLocalUsage, AccountQuotaWindow } from '../constants'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
quotaWindowBarClass,
|
||||
quotaWindowBarStyle,
|
||||
quotaWindowPercentTextClass,
|
||||
} from '../constants'
|
||||
|
||||
type UsageWindowVariant = 'compact' | 'detail' | 'metric'
|
||||
type UsageWindowMode = 'quota' | 'local' | 'unknown'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
window?: AccountQuotaWindow
|
||||
variant?: UsageWindowVariant
|
||||
showLocalValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'detail',
|
||||
showLocalValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const isCompact = computed(() => props.variant === 'compact')
|
||||
const isMetric = computed(() => props.variant === 'metric')
|
||||
const localUsage = computed<AccountLocalUsage | null>(() => {
|
||||
const value = props.window?.localUsage
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return null
|
||||
return value as AccountLocalUsage
|
||||
})
|
||||
const mode = computed<UsageWindowMode>(() => {
|
||||
if (typeof props.window?.usedPercent === 'number')
|
||||
return 'quota'
|
||||
if (localUsage.value)
|
||||
return 'local'
|
||||
if (props.window)
|
||||
return 'quota'
|
||||
return 'unknown'
|
||||
})
|
||||
const quotaValueVisible = computed(() =>
|
||||
typeof props.window?.usedPercent === 'number' && props.window.usedPercent > 0,
|
||||
)
|
||||
const quotaLocalUsageDisplay = computed(() => {
|
||||
const display = localUsage.value?.totalTokensDisplay
|
||||
if (typeof display === 'string' && display.trim())
|
||||
return display.trim()
|
||||
const total = localUsage.value?.totalTokens
|
||||
return typeof total === 'number' && total > 0 ? total.toLocaleString() : ''
|
||||
})
|
||||
const quotaLocalUsageVisible = computed(() =>
|
||||
props.showLocalValue
|
||||
&& typeof localUsage.value?.totalTokens === 'number'
|
||||
&& localUsage.value.totalTokens > 0,
|
||||
)
|
||||
|
||||
function variantValue(metric: string, compact: string, detail: string) {
|
||||
if (isMetric.value)
|
||||
return metric
|
||||
if (isCompact.value)
|
||||
return compact
|
||||
return detail
|
||||
}
|
||||
|
||||
const rootClass = computed(() =>
|
||||
variantValue(
|
||||
'grid min-w-0 grid-rows-[14px_14px] gap-1',
|
||||
'flex min-w-0 flex-col',
|
||||
'rounded-lg bg-cp-subtle p-2',
|
||||
),
|
||||
)
|
||||
const headerClass = computed(() =>
|
||||
variantValue(
|
||||
'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
'flex items-center justify-between gap-3 text-[12px] font-bold',
|
||||
),
|
||||
)
|
||||
const labelClass = computed(() => {
|
||||
if (isMetric.value)
|
||||
return 'truncate text-cp-muted-text'
|
||||
if (isCompact.value)
|
||||
return 'truncate text-cp-muted-text'
|
||||
return 'text-cp-secondary'
|
||||
})
|
||||
const valueClass = computed(() =>
|
||||
variantValue(
|
||||
'text-[11px] leading-none font-bold',
|
||||
'text-[10px] leading-[14px] font-heavy',
|
||||
'text-[12px] font-heavy',
|
||||
),
|
||||
)
|
||||
const trackOffsetClass = computed(() =>
|
||||
variantValue('self-center', 'mt-auto', 'mt-2'),
|
||||
)
|
||||
const trackShapeClass = computed(() =>
|
||||
variantValue(
|
||||
'h-1.5 w-full overflow-hidden rounded-full',
|
||||
'h-[3px] w-full overflow-hidden rounded-full',
|
||||
'h-2 overflow-hidden rounded-full',
|
||||
),
|
||||
)
|
||||
const trackClass = computed(() => `${trackShapeClass.value} bg-cp-default-border`)
|
||||
const barStyle = computed(() => {
|
||||
if (!props.window)
|
||||
return undefined
|
||||
return quotaWindowBarStyle(
|
||||
props.window,
|
||||
variantValue('6px', '3px', '8px'),
|
||||
)
|
||||
})
|
||||
const barClass = computed(() => props.window ? quotaWindowBarClass(props.window) : undefined)
|
||||
const percentTextClass = computed(() =>
|
||||
props.window ? quotaWindowPercentTextClass(props.window) : undefined,
|
||||
)
|
||||
const localRequestLabel = '日请求'
|
||||
const localRequestDisplay = computed(() => {
|
||||
const display = localUsage.value?.requestCountDisplay
|
||||
if (typeof display === 'string' && display.trim())
|
||||
return display.trim()
|
||||
const count = localUsage.value?.requestCount
|
||||
return typeof count === 'number' ? count.toLocaleString() : '0'
|
||||
})
|
||||
const localRequestValueVisible = computed(() => {
|
||||
const count = localUsage.value?.requestCount
|
||||
return props.showLocalValue && typeof count === 'number' && count > 0
|
||||
})
|
||||
const requestTimelineTitle = computed(() => `${localRequestLabel} ${localRequestDisplay.value} 次`)
|
||||
const requestBars = computed(() => {
|
||||
const buckets = localUsage.value?.requestBuckets ?? []
|
||||
const hourMilliseconds = 60 * 60 * 1_000
|
||||
const currentHour = Math.floor(Date.now() / hourMilliseconds) * hourMilliseconds
|
||||
const bucketCounts = new Map(
|
||||
buckets.map((bucket) => {
|
||||
const bucketHour = Math.floor(new Date(bucket.bucketStart).getTime() / hourMilliseconds)
|
||||
* hourMilliseconds
|
||||
return [bucketHour, Math.max(0, bucket.requestCount)] as const
|
||||
}),
|
||||
)
|
||||
const slots = Array.from({ length: 24 }, (_, index) => {
|
||||
const startTime = currentHour - (23 - index) * hourMilliseconds
|
||||
return {
|
||||
startTime,
|
||||
requestCount: bucketCounts.get(startTime) ?? 0,
|
||||
}
|
||||
})
|
||||
const maximum = Math.max(1, ...slots.map(slot => slot.requestCount))
|
||||
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
return slots.map((slot) => {
|
||||
const start = new Date(slot.startTime)
|
||||
const end = new Date(slot.startTime + hourMilliseconds)
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
requestCount: slot.requestCount,
|
||||
height: slot.requestCount === 0
|
||||
? '0'
|
||||
: `${Math.max(25, Math.round(slot.requestCount / maximum * 100))}%`,
|
||||
title: `${formatter.format(start)}–${formatter.format(end)} · ${slot.requestCount} 次请求`,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="rootClass">
|
||||
<template v-if="mode === 'quota' && window">
|
||||
<div :class="headerClass">
|
||||
<span
|
||||
class="min-w-0"
|
||||
:class="labelClass"
|
||||
:title="isCompact || isMetric ? window.labelDisplay : undefined"
|
||||
>
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isCompact"
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-right text-cp-muted-text"
|
||||
:class="valueClass"
|
||||
:title="`本地 Token 用量:${quotaLocalUsageDisplay}`"
|
||||
>
|
||||
{{ quotaLocalUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="quotaValueVisible"
|
||||
class="text-right"
|
||||
:class="[valueClass, percentTextClass]"
|
||||
:title="`额度已使用:${window.usedPercentDisplay}`"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-cp-muted-text"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ quotaLocalUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="quotaValueVisible"
|
||||
:class="[valueClass, percentTextClass]"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[trackClass, trackOffsetClass]"
|
||||
role="progressbar"
|
||||
:aria-label="window.labelDisplay"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="window.usedPercent ?? undefined"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200"
|
||||
:class="barClass"
|
||||
:style="barStyle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isCompact && window.resetAtDisplay !== '—'"
|
||||
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
重置时间: {{ window.resetAtDisplay }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="mode === 'local'">
|
||||
<div :class="headerClass">
|
||||
<span class="min-w-0" :class="labelClass">
|
||||
{{ localRequestLabel }}
|
||||
</span>
|
||||
<strong
|
||||
v-if="localRequestValueVisible"
|
||||
class="shrink-0 font-mono tabular-nums text-cp-primary"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ localRequestDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-stretch gap-px"
|
||||
:class="[trackShapeClass, trackOffsetClass]"
|
||||
role="img"
|
||||
:aria-label="requestTimelineTitle"
|
||||
:title="requestTimelineTitle"
|
||||
>
|
||||
<span
|
||||
v-for="bar in 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="headerClass">
|
||||
<span class="min-w-0 text-cp-secondary">额度待观测</span>
|
||||
<span class="shrink-0 font-mono text-cp-muted-text" :class="valueClass">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountQuotaWindow } from '../../constants'
|
||||
import type { AccountUsageWindowVariant } from './presenter'
|
||||
import { computed } from 'vue'
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import { resolveAccountUsageWindowPresentation } from './presenter'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
window?: AccountQuotaWindow
|
||||
variant?: AccountUsageWindowVariant
|
||||
showLocalValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'detail',
|
||||
showLocalValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const now = useUiClock()
|
||||
const view = computed(() => resolveAccountUsageWindowPresentation({
|
||||
window: props.window,
|
||||
variant: props.variant,
|
||||
showLocalValue: props.showLocalValue,
|
||||
now: now.value.getTime(),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="view.classes.root">
|
||||
<template v-if="view.mode === 'quota' && window">
|
||||
<div :class="view.classes.header">
|
||||
<span
|
||||
class="min-w-0"
|
||||
:class="view.classes.label"
|
||||
:title="view.labelTooltip"
|
||||
>
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="view.compactValues"
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="view.quota.localUsageVisible"
|
||||
class="text-right text-cp-muted-text"
|
||||
:class="view.classes.value"
|
||||
:title="`本地 Token 用量:${view.quota.localUsageDisplay}`"
|
||||
>
|
||||
{{ view.quota.localUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="view.quota.valueVisible"
|
||||
class="text-right"
|
||||
:class="[view.classes.value, view.quota.percentTextClass]"
|
||||
:title="`额度已使用:${window.usedPercentDisplay}`"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="view.quota.localUsageVisible"
|
||||
class="text-cp-muted-text"
|
||||
:class="view.classes.value"
|
||||
>
|
||||
{{ view.quota.localUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="view.quota.valueVisible"
|
||||
:class="[view.classes.value, view.quota.percentTextClass]"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[view.classes.track, view.classes.trackOffset]"
|
||||
role="progressbar"
|
||||
:aria-label="window.labelDisplay"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="window.usedPercent ?? undefined"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200"
|
||||
:class="view.quota.barClass"
|
||||
:style="view.quota.barStyle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="view.quota.resetVisible"
|
||||
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
重置时间: {{ window.resetAtDisplay }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view.mode === 'local'">
|
||||
<div :class="view.classes.header">
|
||||
<span class="min-w-0" :class="view.classes.label">
|
||||
{{ view.local.label }}
|
||||
</span>
|
||||
<strong
|
||||
v-if="view.local.requestValueVisible"
|
||||
class="shrink-0 font-mono tabular-nums text-cp-primary"
|
||||
:class="view.classes.value"
|
||||
>
|
||||
{{ view.local.requestDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-stretch gap-px"
|
||||
: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">
|
||||
<span class="min-w-0 text-cp-secondary">额度待观测</span>
|
||||
<span class="shrink-0 font-mono text-cp-muted-text" :class="view.classes.value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { AccountQuotaWindow } from '../../constants'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { formatInteger } from '@/utils/number'
|
||||
import { isRecord } from '@/utils/object'
|
||||
|
||||
export type AccountUsageWindowVariant = 'compact' | 'detail' | 'metric'
|
||||
|
||||
type AccountUsageWindowMode = 'quota' | 'local' | 'unknown'
|
||||
|
||||
interface AccountRequestBucket {
|
||||
bucketStart: string
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
interface AccountLocalUsage {
|
||||
requestCount?: number
|
||||
requestCountDisplay?: string
|
||||
totalTokens?: number
|
||||
totalTokensDisplay?: string
|
||||
requestBuckets?: AccountRequestBucket[]
|
||||
}
|
||||
|
||||
interface AccountUsageWindowVariantDefinition {
|
||||
rootClass: string
|
||||
headerClass: string
|
||||
labelClass: string
|
||||
valueClass: string
|
||||
trackOffsetClass: string
|
||||
trackShapeClass: string
|
||||
minimumBarWidth: string
|
||||
compactValues: boolean
|
||||
labelTooltip: boolean
|
||||
showResetTime: boolean
|
||||
}
|
||||
|
||||
type QuotaWindowTone = 'danger' | 'success' | 'unknown' | 'warning'
|
||||
|
||||
export interface AccountUsageWindowPresentationInput {
|
||||
window: AccountQuotaWindow | undefined
|
||||
variant: AccountUsageWindowVariant
|
||||
showLocalValue: boolean
|
||||
now: number
|
||||
}
|
||||
|
||||
const variantDefinitions: Record<
|
||||
AccountUsageWindowVariant,
|
||||
AccountUsageWindowVariantDefinition
|
||||
> = {
|
||||
metric: {
|
||||
rootClass: 'grid min-w-0 grid-rows-[14px_14px] gap-1',
|
||||
headerClass: 'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
labelClass: 'truncate text-cp-muted-text',
|
||||
valueClass: 'text-[11px] leading-none font-bold',
|
||||
trackOffsetClass: 'self-center',
|
||||
trackShapeClass: 'h-1.5 w-full overflow-hidden rounded-full',
|
||||
minimumBarWidth: '6px',
|
||||
compactValues: false,
|
||||
labelTooltip: true,
|
||||
showResetTime: true,
|
||||
},
|
||||
compact: {
|
||||
rootClass: 'flex min-w-0 flex-col',
|
||||
headerClass: 'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
labelClass: 'truncate text-cp-muted-text',
|
||||
valueClass: 'text-[10px] leading-[14px] font-heavy',
|
||||
trackOffsetClass: 'mt-auto',
|
||||
trackShapeClass: 'h-[3px] w-full overflow-hidden rounded-full',
|
||||
minimumBarWidth: '3px',
|
||||
compactValues: true,
|
||||
labelTooltip: true,
|
||||
showResetTime: false,
|
||||
},
|
||||
detail: {
|
||||
rootClass: 'rounded-lg bg-cp-subtle p-2',
|
||||
headerClass: 'flex items-center justify-between gap-3 text-[12px] font-bold',
|
||||
labelClass: 'text-cp-secondary',
|
||||
valueClass: 'text-[12px] font-heavy',
|
||||
trackOffsetClass: 'mt-2',
|
||||
trackShapeClass: 'h-2 overflow-hidden rounded-full',
|
||||
minimumBarWidth: '8px',
|
||||
compactValues: false,
|
||||
labelTooltip: false,
|
||||
showResetTime: true,
|
||||
},
|
||||
}
|
||||
|
||||
const hourMilliseconds = 60 * 60 * 1_000
|
||||
const hourFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
const quotaToneClasses: Record<
|
||||
QuotaWindowTone,
|
||||
{ bar: string, text: string }
|
||||
> = {
|
||||
unknown: {
|
||||
bar: 'bg-cp-default-border-hover',
|
||||
text: 'text-cp-muted-text',
|
||||
},
|
||||
danger: {
|
||||
bar: 'bg-cp-danger',
|
||||
text: 'text-cp-danger-text',
|
||||
},
|
||||
warning: {
|
||||
bar: 'bg-cp-warning',
|
||||
text: 'text-cp-warning-text',
|
||||
},
|
||||
success: {
|
||||
bar: 'bg-cp-success',
|
||||
text: 'text-cp-success-text',
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveAccountUsageWindowPresentation(
|
||||
input: AccountUsageWindowPresentationInput,
|
||||
) {
|
||||
const definition = variantDefinitions[input.variant]
|
||||
const localUsage = accountLocalUsage(input.window?.localUsage)
|
||||
const mode = usageWindowMode(input.window, localUsage)
|
||||
const quotaLocalUsageDisplay = localTokenDisplay(localUsage)
|
||||
const quotaLocalUsageVisible = input.showLocalValue
|
||||
&& typeof localUsage?.totalTokens === 'number'
|
||||
&& localUsage.totalTokens > 0
|
||||
const localRequestDisplay = requestCountDisplay(localUsage)
|
||||
const localRequestValueVisible = input.showLocalValue
|
||||
&& typeof localUsage?.requestCount === 'number'
|
||||
&& localUsage.requestCount > 0
|
||||
const localRequestLabel = '日请求'
|
||||
const quota = input.window
|
||||
? quotaWindowPresentation(input.window, definition.minimumBarWidth)
|
||||
: null
|
||||
|
||||
return {
|
||||
mode,
|
||||
compactValues: definition.compactValues,
|
||||
labelTooltip: definition.labelTooltip ? input.window?.labelDisplay : undefined,
|
||||
classes: {
|
||||
root: definition.rootClass,
|
||||
header: definition.headerClass,
|
||||
label: definition.labelClass,
|
||||
value: definition.valueClass,
|
||||
trackOffset: definition.trackOffsetClass,
|
||||
trackShape: definition.trackShapeClass,
|
||||
track: `${definition.trackShapeClass} bg-cp-default-border`,
|
||||
},
|
||||
quota: {
|
||||
valueVisible: typeof input.window?.usedPercent === 'number'
|
||||
&& input.window.usedPercent > 0,
|
||||
localUsageDisplay: quotaLocalUsageDisplay,
|
||||
localUsageVisible: quotaLocalUsageVisible,
|
||||
barStyle: quota?.barStyle,
|
||||
barClass: quota?.barClass,
|
||||
percentTextClass: quota?.percentTextClass,
|
||||
resetVisible: Boolean(input.window)
|
||||
&& definition.showResetTime
|
||||
&& input.window?.resetAtDisplay !== '—',
|
||||
},
|
||||
local: {
|
||||
label: localRequestLabel,
|
||||
requestDisplay: localRequestDisplay,
|
||||
requestValueVisible: localRequestValueVisible,
|
||||
timelineTitle: `${localRequestLabel} ${localRequestDisplay} 次`,
|
||||
requestBars: requestTimeline(localUsage?.requestBuckets ?? [], input.now),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function accountLocalUsage(value: unknown): AccountLocalUsage | null {
|
||||
if (!isRecord(value))
|
||||
return null
|
||||
|
||||
const localUsage: AccountLocalUsage = {
|
||||
requestBuckets: requestBuckets(value.requestBuckets),
|
||||
}
|
||||
if (finiteNumber(value.requestCount))
|
||||
localUsage.requestCount = value.requestCount
|
||||
if (typeof value.requestCountDisplay === 'string')
|
||||
localUsage.requestCountDisplay = value.requestCountDisplay
|
||||
if (finiteNumber(value.totalTokens))
|
||||
localUsage.totalTokens = value.totalTokens
|
||||
if (typeof value.totalTokensDisplay === 'string')
|
||||
localUsage.totalTokensDisplay = value.totalTokensDisplay
|
||||
return localUsage
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function requestBuckets(value: unknown) {
|
||||
if (!Array.isArray(value))
|
||||
return []
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!isRecord(item))
|
||||
return []
|
||||
if (
|
||||
typeof item.bucketStart !== 'string'
|
||||
|| !finiteNumber(item.requestCount)
|
||||
|| !Number.isFinite(new Date(item.bucketStart).getTime())
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
bucketStart: item.bucketStart,
|
||||
requestCount: item.requestCount,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function usageWindowMode(
|
||||
window: AccountQuotaWindow | undefined,
|
||||
localUsage: AccountLocalUsage | null,
|
||||
): AccountUsageWindowMode {
|
||||
if (typeof window?.usedPercent === 'number')
|
||||
return 'quota'
|
||||
if (localUsage)
|
||||
return 'local'
|
||||
return window ? 'quota' : 'unknown'
|
||||
}
|
||||
|
||||
function localTokenDisplay(localUsage: AccountLocalUsage | null) {
|
||||
const display = localUsage?.totalTokensDisplay
|
||||
if (typeof display === 'string' && display.trim())
|
||||
return display.trim()
|
||||
const total = localUsage?.totalTokens
|
||||
return typeof total === 'number' && total > 0 ? formatInteger(total) : ''
|
||||
}
|
||||
|
||||
function requestCountDisplay(localUsage: AccountLocalUsage | null) {
|
||||
const display = localUsage?.requestCountDisplay
|
||||
if (typeof display === 'string' && display.trim())
|
||||
return display.trim()
|
||||
const count = localUsage?.requestCount
|
||||
return typeof count === 'number' ? formatInteger(count) : '0'
|
||||
}
|
||||
|
||||
function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
|
||||
const percent = clamp(window.usedPercent ?? 0, 0, 100)
|
||||
const tone = quotaWindowTone(window.usedPercent)
|
||||
return {
|
||||
barStyle: {
|
||||
width: `${percent}%`,
|
||||
minWidth: percent > 0 ? minimumWidth : '0',
|
||||
},
|
||||
barClass: quotaToneClasses[tone].bar,
|
||||
percentTextClass: quotaToneClasses[tone].text,
|
||||
}
|
||||
}
|
||||
|
||||
function quotaWindowTone(usedPercent: number | null): QuotaWindowTone {
|
||||
if (usedPercent === null)
|
||||
return 'unknown'
|
||||
if (usedPercent >= 95)
|
||||
return 'danger'
|
||||
if (usedPercent >= 80)
|
||||
return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function requestTimeline(
|
||||
buckets: NonNullable<AccountLocalUsage['requestBuckets']>,
|
||||
now: number,
|
||||
) {
|
||||
const currentHour = Math.floor(now / hourMilliseconds) * hourMilliseconds
|
||||
const bucketCounts = new Map(
|
||||
buckets.map((bucket) => {
|
||||
const bucketHour = Math.floor(new Date(bucket.bucketStart).getTime() / hourMilliseconds)
|
||||
* hourMilliseconds
|
||||
return [bucketHour, Math.max(0, bucket.requestCount)] as const
|
||||
}),
|
||||
)
|
||||
const slots = Array.from({ length: 24 }, (_, index) => {
|
||||
const startTime = currentHour - (23 - index) * hourMilliseconds
|
||||
return {
|
||||
startTime,
|
||||
requestCount: bucketCounts.get(startTime) ?? 0,
|
||||
}
|
||||
})
|
||||
const maximum = Math.max(1, ...slots.map(slot => slot.requestCount))
|
||||
|
||||
return slots.map((slot) => {
|
||||
const start = new Date(slot.startTime)
|
||||
const end = new Date(slot.startTime + hourMilliseconds)
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
requestCount: slot.requestCount,
|
||||
height: slot.requestCount === 0
|
||||
? '0'
|
||||
: `${Math.max(25, Math.round(slot.requestCount / maximum * 100))}%`,
|
||||
title: `${hourFormatter.format(start)}–${hourFormatter.format(end)} · ${slot.requestCount} 次请求`,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { isRecord } from '@/utils/object'
|
||||
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
|
||||
import { emptyAccountCreateForm } from '../components/AccountCreateModal/model'
|
||||
|
||||
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
type ImportProvider = 'openai' | 'xai'
|
||||
@@ -27,7 +30,7 @@ export function useAccountOnboarding(options: {
|
||||
const authorizingOAuthAction = useAsyncAction()
|
||||
const creatingAccount = creatingAccountAction.loading
|
||||
const authorizingOAuth = authorizingOAuthAction.loading
|
||||
const createForm = ref(emptyCreateForm())
|
||||
const createForm = ref(emptyAccountCreateForm())
|
||||
|
||||
const showCreateModal = computed({
|
||||
get: () => createModalOpen.value,
|
||||
@@ -35,7 +38,7 @@ export function useAccountOnboarding(options: {
|
||||
createModalOpen.value = value
|
||||
if (!value) {
|
||||
reauthorizingAccount.value = null
|
||||
createForm.value = emptyCreateForm()
|
||||
createForm.value = emptyAccountCreateForm()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -124,7 +127,7 @@ export function useAccountOnboarding(options: {
|
||||
|
||||
function openCreateAccount() {
|
||||
reauthorizingAccount.value = null
|
||||
createForm.value = emptyCreateForm()
|
||||
createForm.value = emptyAccountCreateForm()
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ export function useAccountOnboarding(options: {
|
||||
return
|
||||
reauthorizingAccount.value = account
|
||||
createForm.value = {
|
||||
...emptyCreateForm(),
|
||||
...emptyAccountCreateForm(),
|
||||
provider: account.provider,
|
||||
name: account.name,
|
||||
mode: 'oauth',
|
||||
@@ -164,7 +167,7 @@ export function useAccountOnboarding(options: {
|
||||
})
|
||||
importedCount += result.importedCount
|
||||
}
|
||||
return `${providerDisplayName(provider) ?? provider} 账号已导入 ${importedCount} 个`
|
||||
return `${formatProviderLabel(provider)} 账号已导入 ${importedCount} 个`
|
||||
}
|
||||
|
||||
async function importMixedAccountDocument() {
|
||||
@@ -181,10 +184,8 @@ export function useAccountOnboarding(options: {
|
||||
importedCount += result.importedCount
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '导入失败'
|
||||
failures.push(`${providerDisplayName(entry.provider) ?? 'OpenAI'}:${message}`)
|
||||
const message = errorMessage(error, '导入失败')
|
||||
failures.push(`${formatProviderLabel(entry.provider, 'OpenAI')}:${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,34 +231,6 @@ export function useAccountOnboarding(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function emptyCreateForm() {
|
||||
return {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'oauth',
|
||||
importText: '',
|
||||
oauthFlowId: '',
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function accountProviderModeOptions(provider: string) {
|
||||
if (provider === 'xai') {
|
||||
return [
|
||||
{ label: 'OAuth', value: 'oauth' },
|
||||
{ label: '账号文件', value: 'json' },
|
||||
]
|
||||
}
|
||||
if (provider !== 'openai')
|
||||
return []
|
||||
return [
|
||||
{ label: 'OAuth', value: 'oauth' },
|
||||
{ label: '账号文件', value: 'json' },
|
||||
{ label: 'Agent 身份', value: 'agent_identity' },
|
||||
]
|
||||
}
|
||||
|
||||
function parseImportJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
@@ -268,7 +241,7 @@ function parseImportJson(value: string) {
|
||||
}
|
||||
|
||||
function requireImportProvider(value: string): ImportProvider {
|
||||
if (value === 'openai' || value === 'xai')
|
||||
if (isSupportedProvider(value))
|
||||
return value
|
||||
throw new Error('请选择要导入的账号平台')
|
||||
}
|
||||
@@ -278,7 +251,7 @@ function providerImportDocuments(value: unknown, provider: ImportProvider): Mixe
|
||||
const documents = parseMixedImportDocuments(value)
|
||||
.filter(entry => entry.provider === provider)
|
||||
if (documents.length === 0) {
|
||||
const label = providerDisplayName(provider) ?? provider
|
||||
const label = formatProviderLabel(provider)
|
||||
throw new Error(`批量导入文件不包含 ${label} 账号文档`)
|
||||
}
|
||||
return documents
|
||||
@@ -297,7 +270,7 @@ function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
|
||||
if (!isRecord(entry))
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
const provider = entry.provider
|
||||
if (provider !== 'openai' && provider !== 'xai')
|
||||
if (!isSupportedProvider(provider))
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
if (!isRecord(entry.document))
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
@@ -308,7 +281,3 @@ function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
|
||||
throw new Error('批量文件没有可导入的账号文档')
|
||||
return documents
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import type { AccountErrorReason, AccountStatus, getAccounts } from '@/api'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
export type AccountQuotaWindow = AccountRow['quota']['windows'][number]
|
||||
|
||||
export interface AccountRequestBucket {
|
||||
bucketStart: string
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface AccountLocalUsage {
|
||||
requestCount?: number
|
||||
requestCountDisplay?: string
|
||||
totalTokens?: number
|
||||
totalTokensDisplay?: string
|
||||
requestBuckets?: AccountRequestBucket[]
|
||||
}
|
||||
|
||||
const quotaGroupOrder = new Map([
|
||||
['shortTerm', 0],
|
||||
['monthly', 1],
|
||||
@@ -118,38 +104,6 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) {
|
||||
return [...windows].sort(compareQuotaWindows)
|
||||
}
|
||||
|
||||
function quotaWindowPercent(window: AccountQuotaWindow) {
|
||||
return clamp(window.usedPercent ?? 0, 0, 100)
|
||||
}
|
||||
|
||||
export function quotaWindowBarStyle(window: AccountQuotaWindow, minimumWidth = '8px') {
|
||||
const percent = quotaWindowPercent(window)
|
||||
return {
|
||||
width: `${percent}%`,
|
||||
minWidth: percent > 0 ? minimumWidth : '0',
|
||||
}
|
||||
}
|
||||
|
||||
export function quotaWindowBarClass(window: AccountQuotaWindow) {
|
||||
if (window.usedPercent === null)
|
||||
return 'bg-cp-default-border-hover'
|
||||
if (window.usedPercent >= 95)
|
||||
return 'bg-cp-danger'
|
||||
if (window.usedPercent >= 80)
|
||||
return 'bg-cp-warning'
|
||||
return 'bg-cp-success'
|
||||
}
|
||||
|
||||
export function quotaWindowPercentTextClass(window: AccountQuotaWindow) {
|
||||
if (window.usedPercent === null)
|
||||
return 'text-cp-muted-text'
|
||||
if (window.usedPercent >= 95)
|
||||
return 'text-cp-danger-text'
|
||||
if (window.usedPercent >= 80)
|
||||
return 'text-cp-warning-text'
|
||||
return 'text-cp-success-text'
|
||||
}
|
||||
|
||||
export function modelSuccessRateTextClass(successRate: number | null) {
|
||||
if (successRate === null)
|
||||
return 'text-cp-muted-text'
|
||||
@@ -181,7 +135,7 @@ function groupOrder(window: AccountQuotaWindow) {
|
||||
}
|
||||
|
||||
function accountProviderLabel(value?: string | null) {
|
||||
return providerDisplayName(value) ?? (value || '—')
|
||||
return formatProviderLabel(value)
|
||||
}
|
||||
|
||||
function optionalAccountCell(value: unknown) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import { useAccountGroupCatalog } from '@/composables/useAccountGroupCatalog'
|
||||
import AccountBatchEditModal from './components/AccountBatchEditModal.vue'
|
||||
import AccountConnectionTestModal from './components/AccountConnectionTestModal.vue'
|
||||
import AccountCreateModal from './components/AccountCreateModal.vue'
|
||||
import AccountCreateModal from './components/AccountCreateModal/index.vue'
|
||||
import AccountEditModal from './components/AccountEditModal.vue'
|
||||
import AccountFilters from './components/AccountFilters.vue'
|
||||
import AccountIdentityCell from './components/AccountIdentityCell.vue'
|
||||
@@ -22,7 +22,7 @@ import AccountOverviewCards from './components/AccountOverviewCards.vue'
|
||||
import AccountPlanBadge from './components/AccountPlanBadge.vue'
|
||||
import AccountQuotaPanel from './components/AccountQuotaPanel.vue'
|
||||
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
|
||||
import AccountStatusBadge from './components/AccountStatusBadge.vue'
|
||||
import AccountStatusBadge from './components/AccountStatusBadge/index.vue'
|
||||
import AccountTableActions from './components/AccountTableActions.vue'
|
||||
import AccountUsagePanel from './components/AccountUsagePanel.vue'
|
||||
import { useAccountBatchEditor } from './composables/useAccountBatchEditor'
|
||||
@@ -248,6 +248,7 @@ const {
|
||||
:error-reason="row.errorReason"
|
||||
:error-message="row.errorMessage"
|
||||
:rate-limited-until="row.quota.rateLimitedUntil"
|
||||
:next-refresh-at="row.nextRefreshAt"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
groups: AccountGroup[]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { dashboardSnapshotView, MetricTone } from '../composables/presenter'
|
||||
import type { dashboardSnapshotView, MetricTone } from '../composables/useDashboard'
|
||||
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import { formatCompactNumber } from '@/utils/number'
|
||||
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
|
||||
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow.vue'
|
||||
import { formatDashboardCompactNumber } from '../composables/presenter'
|
||||
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow/index.vue'
|
||||
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
|
||||
|
||||
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
|
||||
@@ -24,7 +24,7 @@ const scheduleStats = computed(() => {
|
||||
const cap = props.capacity
|
||||
const display = (value: number | null | undefined) => value === null || value === undefined
|
||||
? '—'
|
||||
: formatDashboardCompactNumber(value)
|
||||
: formatCompactNumber(value)
|
||||
return [
|
||||
{ label: '默认并发', value: display(cap?.maxConcurrentPerAccount) },
|
||||
{ label: '总槽位', value: display(cap?.totalSlots) },
|
||||
@@ -48,8 +48,8 @@ const usedRatio = computed(() => {
|
||||
if (!cap || cap.totalSlots === null || cap.totalSlots === undefined)
|
||||
return '— / —'
|
||||
if (cap.usedSlots === null || cap.usedSlots === undefined)
|
||||
return `— / ${formatDashboardCompactNumber(cap.totalSlots)}`
|
||||
return `${formatDashboardCompactNumber(cap.usedSlots)} / ${formatDashboardCompactNumber(cap.totalSlots)}`
|
||||
return `— / ${formatCompactNumber(cap.totalSlots)}`
|
||||
return `${formatCompactNumber(cap.usedSlots)} / ${formatCompactNumber(cap.totalSlots)}`
|
||||
})
|
||||
|
||||
const strategyLabel = computed(() => {
|
||||
@@ -225,9 +225,7 @@ const statusBars = computed(() => {
|
||||
>
|
||||
<BaseEmpty
|
||||
v-if="accounts.length === 0"
|
||||
size="sm"
|
||||
title="暂无账号用量"
|
||||
description="暂无账号请求记录"
|
||||
title="暂无账号请求记录"
|
||||
class="min-h-40 place-content-center xl:h-full"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -247,7 +245,6 @@ const statusBars = computed(() => {
|
||||
<ProviderIconGroup
|
||||
:provider="account.provider"
|
||||
:authentication-kind="account.authenticationKind"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
</AccountIdentityCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { MetricCardView, MetricTone } from '../composables/presenter'
|
||||
import type { MetricCardView, MetricTone } from '../composables/useDashboard'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../composables/presenter'
|
||||
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../composables/useDashboard'
|
||||
|
||||
import { toRef } from 'vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
@@ -97,10 +97,8 @@ const {
|
||||
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
|
||||
<BaseEmpty
|
||||
v-if="!hasSamples"
|
||||
size="sm"
|
||||
surface="none"
|
||||
title="暂无趋势数据"
|
||||
description="当日暂无请求日志"
|
||||
class="h-full place-content-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { dashboardSnapshotView } from '../composables/presenter'
|
||||
import type { dashboardSnapshotView } from '../composables/useDashboard'
|
||||
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import UsageRecordsTable from '@/views/usage/components/UsageRecordsTable.vue'
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
interface WireProfile {
|
||||
provider: string
|
||||
@@ -174,7 +174,7 @@ watch(
|
||||
)
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
return providerDisplayName(provider) ?? provider
|
||||
return formatProviderLabel(provider)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -198,7 +198,6 @@ function providerLabel(provider: string) {
|
||||
<template #body>
|
||||
<BaseEmpty
|
||||
v-if="!profile"
|
||||
size="sm"
|
||||
title="暂无请求身份"
|
||||
class="min-h-71.75 flex-1 place-content-center"
|
||||
/>
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { Activity, FileText, Timer, Users } from '@lucide/vue'
|
||||
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { normalizeUsageRecord } from '@/api'
|
||||
|
||||
type DashboardSummary = Awaited<ReturnType<typeof getDashboardSummary>>
|
||||
type DashboardTrend = Awaited<ReturnType<typeof getDashboardTrend>>
|
||||
type DashboardTrendPoint = DashboardTrend['points'][number]
|
||||
type DashboardTrendKind = DashboardTrend['kind']
|
||||
|
||||
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export interface MetricCardView {
|
||||
title: string
|
||||
value: string
|
||||
valueRaw?: number | null
|
||||
valueFormatter?: (value: number) => string
|
||||
icon: Component
|
||||
tone: MetricTone
|
||||
sparkline?: {
|
||||
values: number[]
|
||||
tone: MetricTone
|
||||
}
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'flat'
|
||||
tone: MetricTone
|
||||
}
|
||||
details: Array<{
|
||||
label: string
|
||||
value: string
|
||||
tone?: MetricTone
|
||||
}>
|
||||
}
|
||||
|
||||
const metricSparklineBuckets = 12
|
||||
|
||||
const emptyCards: DashboardSummary['cards'] = {
|
||||
credentials: {
|
||||
total: '0',
|
||||
totalValue: 0,
|
||||
available: '0',
|
||||
availableValue: 0,
|
||||
unavailable: '0',
|
||||
unavailableValue: 0,
|
||||
},
|
||||
traffic: {
|
||||
todayRequests: '0',
|
||||
todayRequestsValue: 0,
|
||||
yesterdayRequestsValue: 0,
|
||||
totalRequests: '0',
|
||||
},
|
||||
tokens: {
|
||||
todayTokens: '0',
|
||||
todayTokensValue: 0,
|
||||
yesterdayTokensValue: 0,
|
||||
totalTokens: '0',
|
||||
totalBillingAmountUsd: '—',
|
||||
},
|
||||
cache: {
|
||||
todayHitRate: '—',
|
||||
todayHitRateValue: null,
|
||||
yesterdayHitRateValue: null,
|
||||
totalHitRate: '—',
|
||||
totalCachedTokens: '0',
|
||||
averageFirstTokenLatencyMs: '—',
|
||||
},
|
||||
}
|
||||
|
||||
const emptyHealthTimeline: DashboardSummary['healthTimeline'] = {
|
||||
title: '请求健康时间线',
|
||||
description: '有效请求可用性',
|
||||
reliabilityDisplay: '-',
|
||||
status: 'no_data',
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
cancelledRequests: 0,
|
||||
incompleteRequests: 0,
|
||||
callerErrorRequests: 0,
|
||||
points: [],
|
||||
}
|
||||
|
||||
const emptyCapacityInfo = {
|
||||
maxConcurrentPerAccount: null,
|
||||
totalSlots: null,
|
||||
usedSlots: null,
|
||||
availableSlots: null,
|
||||
}
|
||||
|
||||
export function dashboardSnapshotView(summary: DashboardSummary | null) {
|
||||
const trendPoints = summary?.trend.points ?? []
|
||||
return {
|
||||
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
|
||||
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
|
||||
accountUsage: (summary?.accountUsage ?? []).map(accountUsageItem),
|
||||
wireProfiles: summary?.wireProfiles ?? [],
|
||||
usageRecords: (summary?.usageRecords ?? []).map(normalizeUsageRecord),
|
||||
poolSummary: summary?.poolSummary ?? null,
|
||||
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
|
||||
rotationStrategy: summary?.rotationStrategy ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function dashboardTrendView(trend: DashboardTrend | null) {
|
||||
if (!trend) {
|
||||
const points: ReturnType<typeof aggregateUsageTrend> = []
|
||||
const summary: ReturnType<typeof usageTrendSummary> = []
|
||||
return { points, summary }
|
||||
}
|
||||
|
||||
if (trend.kind === 'usage') {
|
||||
const points = aggregateUsageTrend(trend.points)
|
||||
return { points, summary: usageTrendSummary(points) }
|
||||
}
|
||||
|
||||
const summary = []
|
||||
for (const item of trend.summary) {
|
||||
summary.push({
|
||||
label: item.label,
|
||||
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
|
||||
tone: trendSummaryTone(item.label),
|
||||
colorVar: trendSummaryColorVar(trend.kind, item.label),
|
||||
})
|
||||
}
|
||||
return { points: trend.points, summary }
|
||||
}
|
||||
|
||||
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
|
||||
if (kind === 'latency' || kind === 'errors')
|
||||
return kind
|
||||
return 'usage'
|
||||
}
|
||||
|
||||
function metricCards(
|
||||
cards: DashboardSummary['cards'],
|
||||
points: DashboardTrendPoint[],
|
||||
): MetricCardView[] {
|
||||
const { credentials, traffic, tokens, cache } = cards
|
||||
const recentPoints = recentTrendWindow(points)
|
||||
return [
|
||||
{
|
||||
title: '账号',
|
||||
value: credentials.total,
|
||||
valueRaw: credentials.totalValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: Users,
|
||||
tone: 'normal',
|
||||
details: [
|
||||
{
|
||||
label: '可用',
|
||||
value: credentials.available,
|
||||
tone: credentials.availableValue > 0 ? 'success' : 'normal',
|
||||
},
|
||||
{
|
||||
label: '不可用',
|
||||
value: credentials.unavailable,
|
||||
tone: credentials.unavailableValue > 0 ? 'danger' : 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '请求次数',
|
||||
value: traffic.todayRequests,
|
||||
valueRaw: traffic.todayRequestsValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: Activity,
|
||||
tone: 'info',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.requestsValue),
|
||||
'info',
|
||||
),
|
||||
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
|
||||
details: [
|
||||
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
|
||||
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
value: tokens.todayTokens,
|
||||
valueRaw: tokens.todayTokensValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: FileText,
|
||||
tone: 'success',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.tokensValue),
|
||||
'success',
|
||||
),
|
||||
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
|
||||
details: [
|
||||
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
|
||||
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '缓存命中',
|
||||
value: cache.todayHitRate,
|
||||
valueRaw: cache.todayHitRateValue,
|
||||
valueFormatter: formatDashboardRate,
|
||||
icon: Timer,
|
||||
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.cacheHitRateValue),
|
||||
'warning',
|
||||
),
|
||||
trend: trendState(
|
||||
cache.todayHitRateValue ?? 0,
|
||||
cache.yesterdayHitRateValue ?? 0,
|
||||
'warning',
|
||||
),
|
||||
details: [
|
||||
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
|
||||
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function sparkline(values: number[], tone: MetricTone) {
|
||||
return values.some(value => value > 0) ? { values, tone } : undefined
|
||||
}
|
||||
|
||||
function recentTrendWindow(points: DashboardTrendPoint[]) {
|
||||
let lastActiveIndex = points.length - 1
|
||||
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
|
||||
if (lastActiveIndex < 0)
|
||||
return []
|
||||
return points.slice(
|
||||
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
|
||||
lastActiveIndex + 1,
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDashboardCompactNumber(value: number) {
|
||||
const normalized = Math.max(0, Math.round(value))
|
||||
if (normalized < 1_000)
|
||||
return new Intl.NumberFormat('zh-CN').format(normalized)
|
||||
|
||||
for (const [unit, threshold] of [
|
||||
['P', 1_000_000_000_000_000],
|
||||
['T', 1_000_000_000_000],
|
||||
['B', 1_000_000_000],
|
||||
['M', 1_000_000],
|
||||
['K', 1_000],
|
||||
] as const) {
|
||||
if (normalized >= threshold) {
|
||||
const scaled = normalized / threshold
|
||||
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
|
||||
return `${rounded.replace(/\.?0+$/, '')}${unit}`
|
||||
}
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('zh-CN').format(normalized)
|
||||
}
|
||||
|
||||
function formatDashboardRate(value: number) {
|
||||
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
|
||||
}
|
||||
|
||||
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
|
||||
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
|
||||
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
|
||||
const first = group[0]
|
||||
const requestsValue = sum(group, point => point.requestsValue)
|
||||
const errorsValue = sum(group, point => point.errorsValue)
|
||||
const inputTokensValue = sum(group, point => point.inputTokensValue)
|
||||
const outputTokensValue = sum(group, point => point.outputTokensValue)
|
||||
const cachedTokensValue = Math.min(
|
||||
inputTokensValue,
|
||||
sum(group, point => point.cachedTokensValue),
|
||||
)
|
||||
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
|
||||
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
|
||||
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
|
||||
const successRateValue = requestsValue
|
||||
? ((requestsValue - errorsValue) / requestsValue) * 100
|
||||
: null
|
||||
|
||||
return {
|
||||
...first,
|
||||
requests: formatDashboardCompactNumber(requestsValue),
|
||||
requestsValue,
|
||||
inputTokens: formatDashboardCompactNumber(inputTokensValue),
|
||||
inputTokensValue,
|
||||
outputTokens: formatDashboardCompactNumber(outputTokensValue),
|
||||
outputTokensValue,
|
||||
cachedTokens: formatDashboardCompactNumber(cachedTokensValue),
|
||||
cachedTokensValue,
|
||||
uncachedInputTokens: formatDashboardCompactNumber(uncachedInputTokensValue),
|
||||
uncachedInputTokensValue,
|
||||
effectiveTokens: formatDashboardCompactNumber(effectiveTokensValue),
|
||||
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
|
||||
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
|
||||
cacheHitRateValue,
|
||||
tokensValue: inputTokensValue + outputTokensValue,
|
||||
errors: formatDashboardCompactNumber(errorsValue),
|
||||
errorsValue,
|
||||
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
|
||||
successRateValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
|
||||
return points.reduce((total, point) => total + selector(point), 0)
|
||||
}
|
||||
|
||||
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
|
||||
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
|
||||
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
|
||||
const cachedTokens = Math.min(
|
||||
inputTokens,
|
||||
points.reduce((total, point) => total + point.cachedTokensValue, 0),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '输入',
|
||||
value: formatDashboardCompactNumber(inputTokens),
|
||||
tone: 'info',
|
||||
colorVar: '--cp-info',
|
||||
},
|
||||
{
|
||||
label: '输出',
|
||||
value: formatDashboardCompactNumber(outputTokens),
|
||||
tone: 'success',
|
||||
colorVar: '--cp-success',
|
||||
},
|
||||
{
|
||||
label: '缓存',
|
||||
value: formatDashboardCompactNumber(cachedTokens),
|
||||
tone: 'normal',
|
||||
colorVar: '--cp-text-tertiary',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function accountUsageItem(item: DashboardSummary['accountUsage'][number]) {
|
||||
const usageWindow = dashboardUsageWindow(item)
|
||||
const requestCount = usageWindow.localUsage?.requestCount
|
||||
const usesDailyRequests = typeof requestCount === 'number'
|
||||
return {
|
||||
id: item.id,
|
||||
provider: item.provider,
|
||||
authenticationKind: item.authenticationKind,
|
||||
email: item.email,
|
||||
planType: item.planType,
|
||||
tokens: item.tokens,
|
||||
lastUsed: item.lastUsed,
|
||||
metricLabel: usesDailyRequests ? '次数' : '今日 Token',
|
||||
metricValue: usesDailyRequests
|
||||
? requestCount > 0 ? usageWindow.localUsage?.requestCountDisplay : '—'
|
||||
: item.tokens,
|
||||
usageWindow,
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
|
||||
const provider = item.provider.trim().toLowerCase()
|
||||
const planType = item.planType?.trim().toLowerCase() || 'free'
|
||||
if (provider !== 'xai' || planType !== 'free') {
|
||||
const usedPercent = quotaUsedPercent(item.quotaUsedPercent)
|
||||
return {
|
||||
key: 'quota',
|
||||
group: 'other',
|
||||
labelDisplay: '额度',
|
||||
usedPercent,
|
||||
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
|
||||
limitReached: (usedPercent ?? 0) >= 100,
|
||||
windowSeconds: null,
|
||||
resetAtDisplay: '—',
|
||||
}
|
||||
}
|
||||
|
||||
const requestCount = typeof item.requestCount === 'number' && Number.isFinite(item.requestCount)
|
||||
? Math.max(0, item.requestCount)
|
||||
: 0
|
||||
const requestBuckets = Array.isArray(item.requestBuckets)
|
||||
? item.requestBuckets.map((bucket: { bucketStart: string, requestCount: number }) => ({
|
||||
bucketStart: bucket.bucketStart,
|
||||
requestCount: bucket.requestCount,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
key: 'dailyRequests',
|
||||
group: 'other',
|
||||
labelDisplay: '日请求',
|
||||
usedPercent: null,
|
||||
usedPercentDisplay: '—',
|
||||
limitReached: false,
|
||||
windowSeconds: 86_400,
|
||||
resetAtDisplay: '—',
|
||||
localUsage: {
|
||||
requestCount,
|
||||
requestCountDisplay: formatDashboardCompactNumber(requestCount),
|
||||
requestBuckets,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function quotaUsedPercent(value: number | null) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value))
|
||||
return null
|
||||
return clamp(Math.round(value), 0, 100)
|
||||
}
|
||||
|
||||
function formatDashboardUsd(value: string) {
|
||||
const normalized = value.trim()
|
||||
if (!normalized.startsWith('$'))
|
||||
return value
|
||||
const amount = Number(normalized.slice(1).replaceAll(',', ''))
|
||||
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : value
|
||||
}
|
||||
|
||||
function trendState(
|
||||
current: number,
|
||||
previous: number,
|
||||
fallbackTone: MetricTone,
|
||||
): MetricCardView['trend'] {
|
||||
if (current > previous)
|
||||
return { direction: 'up', tone: 'success' }
|
||||
if (current < previous)
|
||||
return { direction: 'down', tone: 'danger' }
|
||||
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
|
||||
}
|
||||
|
||||
function trendSummaryTone(label: string) {
|
||||
if (label.includes('错误'))
|
||||
return 'danger'
|
||||
if (label.includes('最高'))
|
||||
return 'warning'
|
||||
if (label.includes('输出') || label.includes('最低') || label.includes('成功'))
|
||||
return 'success'
|
||||
if (label.includes('缓存'))
|
||||
return 'normal'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
|
||||
if (kind === 'latency') {
|
||||
if (label.includes('最高'))
|
||||
return '--cp-warning'
|
||||
if (label.includes('最低'))
|
||||
return '--cp-success'
|
||||
return '--cp-normal'
|
||||
}
|
||||
if (kind === 'errors') {
|
||||
if (label.includes('错误'))
|
||||
return '--cp-danger'
|
||||
if (label.includes('成功'))
|
||||
return '--cp-success'
|
||||
return '--cp-info'
|
||||
}
|
||||
if (label.includes('输出'))
|
||||
return '--cp-success'
|
||||
if (label.includes('缓存'))
|
||||
return '--cp-text-tertiary'
|
||||
return '--cp-info'
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Component } from 'vue'
|
||||
import { Activity, FileText, Timer, Users } from '@lucide/vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
|
||||
import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
import {
|
||||
dashboardSnapshotView,
|
||||
dashboardTrendView,
|
||||
normalizeDashboardTrendKind,
|
||||
} from './presenter'
|
||||
import { formatCompactNumber } from '@/utils/number'
|
||||
import { normalizeUsageRecord } from '@/views/usage/utils/records'
|
||||
|
||||
export function useDashboard() {
|
||||
const activeTrendKind = shallowRef(normalizeDashboardTrendKind('usage'))
|
||||
@@ -125,3 +124,436 @@ export function useDashboard() {
|
||||
loadTrend,
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardSummary = Awaited<ReturnType<typeof getDashboardSummary>>
|
||||
type DashboardTrend = Awaited<ReturnType<typeof getDashboardTrend>>
|
||||
type DashboardTrendPoint = DashboardTrend['points'][number]
|
||||
type DashboardTrendKind = DashboardTrend['kind']
|
||||
|
||||
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export interface MetricCardView {
|
||||
title: string
|
||||
value: string
|
||||
valueRaw?: number | null
|
||||
valueFormatter?: (value: number) => string
|
||||
icon: Component
|
||||
tone: MetricTone
|
||||
sparkline?: {
|
||||
values: number[]
|
||||
tone: MetricTone
|
||||
}
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'flat'
|
||||
tone: MetricTone
|
||||
}
|
||||
details: Array<{
|
||||
label: string
|
||||
value: string
|
||||
tone?: MetricTone
|
||||
}>
|
||||
}
|
||||
|
||||
const metricSparklineBuckets = 12
|
||||
|
||||
const emptyCards: DashboardSummary['cards'] = {
|
||||
credentials: {
|
||||
total: '0',
|
||||
totalValue: 0,
|
||||
available: '0',
|
||||
availableValue: 0,
|
||||
unavailable: '0',
|
||||
unavailableValue: 0,
|
||||
},
|
||||
traffic: {
|
||||
todayRequests: '0',
|
||||
todayRequestsValue: 0,
|
||||
yesterdayRequestsValue: 0,
|
||||
totalRequests: '0',
|
||||
},
|
||||
tokens: {
|
||||
todayTokens: '0',
|
||||
todayTokensValue: 0,
|
||||
yesterdayTokensValue: 0,
|
||||
totalTokens: '0',
|
||||
totalBillingAmountUsd: '—',
|
||||
},
|
||||
cache: {
|
||||
todayHitRate: '—',
|
||||
todayHitRateValue: null,
|
||||
yesterdayHitRateValue: null,
|
||||
totalHitRate: '—',
|
||||
totalCachedTokens: '0',
|
||||
averageFirstTokenLatencyMs: '—',
|
||||
},
|
||||
}
|
||||
|
||||
const emptyHealthTimeline: DashboardSummary['healthTimeline'] = {
|
||||
title: '请求健康时间线',
|
||||
description: '有效请求可用性',
|
||||
reliabilityDisplay: '-',
|
||||
status: 'no_data',
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
cancelledRequests: 0,
|
||||
incompleteRequests: 0,
|
||||
callerErrorRequests: 0,
|
||||
points: [],
|
||||
}
|
||||
|
||||
const emptyCapacityInfo = {
|
||||
maxConcurrentPerAccount: null,
|
||||
totalSlots: null,
|
||||
usedSlots: null,
|
||||
availableSlots: null,
|
||||
}
|
||||
|
||||
export function dashboardSnapshotView(summary: DashboardSummary | null) {
|
||||
const trendPoints = summary?.trend.points ?? []
|
||||
return {
|
||||
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
|
||||
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
|
||||
accountUsage: (summary?.accountUsage ?? []).map(accountUsageItem),
|
||||
wireProfiles: summary?.wireProfiles ?? [],
|
||||
usageRecords: (summary?.usageRecords ?? []).map(normalizeUsageRecord),
|
||||
poolSummary: summary?.poolSummary ?? null,
|
||||
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
|
||||
rotationStrategy: summary?.rotationStrategy ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function dashboardTrendView(trend: DashboardTrend | null) {
|
||||
if (!trend) {
|
||||
const points: ReturnType<typeof aggregateUsageTrend> = []
|
||||
const summary: ReturnType<typeof usageTrendSummary> = []
|
||||
return { points, summary }
|
||||
}
|
||||
|
||||
if (trend.kind === 'usage') {
|
||||
const points = aggregateUsageTrend(trend.points)
|
||||
return { points, summary: usageTrendSummary(points) }
|
||||
}
|
||||
|
||||
const summary = []
|
||||
for (const item of trend.summary) {
|
||||
summary.push({
|
||||
label: item.label,
|
||||
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
|
||||
tone: trendSummaryTone(item.label),
|
||||
colorVar: trendSummaryColorVar(trend.kind, item.label),
|
||||
})
|
||||
}
|
||||
return { points: trend.points, summary }
|
||||
}
|
||||
|
||||
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
|
||||
if (kind === 'latency' || kind === 'errors')
|
||||
return kind
|
||||
return 'usage'
|
||||
}
|
||||
|
||||
function metricCards(
|
||||
cards: DashboardSummary['cards'],
|
||||
points: DashboardTrendPoint[],
|
||||
): MetricCardView[] {
|
||||
const { credentials, traffic, tokens, cache } = cards
|
||||
const recentPoints = recentTrendWindow(points)
|
||||
return [
|
||||
{
|
||||
title: '账号',
|
||||
value: credentials.total,
|
||||
valueRaw: credentials.totalValue,
|
||||
valueFormatter: formatCompactNumber,
|
||||
icon: Users,
|
||||
tone: 'normal',
|
||||
details: [
|
||||
{
|
||||
label: '可用',
|
||||
value: credentials.available,
|
||||
tone: credentials.availableValue > 0 ? 'success' : 'normal',
|
||||
},
|
||||
{
|
||||
label: '不可用',
|
||||
value: credentials.unavailable,
|
||||
tone: credentials.unavailableValue > 0 ? 'danger' : 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '请求次数',
|
||||
value: traffic.todayRequests,
|
||||
valueRaw: traffic.todayRequestsValue,
|
||||
valueFormatter: formatCompactNumber,
|
||||
icon: Activity,
|
||||
tone: 'info',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.requestsValue),
|
||||
'info',
|
||||
),
|
||||
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
|
||||
details: [
|
||||
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
|
||||
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
value: tokens.todayTokens,
|
||||
valueRaw: tokens.todayTokensValue,
|
||||
valueFormatter: formatCompactNumber,
|
||||
icon: FileText,
|
||||
tone: 'success',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.tokensValue),
|
||||
'success',
|
||||
),
|
||||
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
|
||||
details: [
|
||||
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
|
||||
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '缓存命中',
|
||||
value: cache.todayHitRate,
|
||||
valueRaw: cache.todayHitRateValue,
|
||||
valueFormatter: formatDashboardRate,
|
||||
icon: Timer,
|
||||
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
|
||||
sparkline: sparkline(
|
||||
recentPoints.map(point => point.cacheHitRateValue),
|
||||
'warning',
|
||||
),
|
||||
trend: trendState(
|
||||
cache.todayHitRateValue ?? 0,
|
||||
cache.yesterdayHitRateValue ?? 0,
|
||||
'warning',
|
||||
),
|
||||
details: [
|
||||
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
|
||||
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function sparkline(values: number[], tone: MetricTone) {
|
||||
return values.some(value => value > 0) ? { values, tone } : undefined
|
||||
}
|
||||
|
||||
function recentTrendWindow(points: DashboardTrendPoint[]) {
|
||||
let lastActiveIndex = points.length - 1
|
||||
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
|
||||
if (lastActiveIndex < 0)
|
||||
return []
|
||||
return points.slice(
|
||||
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
|
||||
lastActiveIndex + 1,
|
||||
)
|
||||
}
|
||||
|
||||
function formatDashboardRate(value: number) {
|
||||
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
|
||||
}
|
||||
|
||||
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
|
||||
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
|
||||
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
|
||||
const first = group[0]
|
||||
const requestsValue = sum(group, point => point.requestsValue)
|
||||
const errorsValue = sum(group, point => point.errorsValue)
|
||||
const inputTokensValue = sum(group, point => point.inputTokensValue)
|
||||
const outputTokensValue = sum(group, point => point.outputTokensValue)
|
||||
const cachedTokensValue = Math.min(
|
||||
inputTokensValue,
|
||||
sum(group, point => point.cachedTokensValue),
|
||||
)
|
||||
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
|
||||
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
|
||||
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
|
||||
const successRateValue = requestsValue
|
||||
? ((requestsValue - errorsValue) / requestsValue) * 100
|
||||
: null
|
||||
|
||||
return {
|
||||
...first,
|
||||
requests: formatCompactNumber(requestsValue),
|
||||
requestsValue,
|
||||
inputTokens: formatCompactNumber(inputTokensValue),
|
||||
inputTokensValue,
|
||||
outputTokens: formatCompactNumber(outputTokensValue),
|
||||
outputTokensValue,
|
||||
cachedTokens: formatCompactNumber(cachedTokensValue),
|
||||
cachedTokensValue,
|
||||
uncachedInputTokens: formatCompactNumber(uncachedInputTokensValue),
|
||||
uncachedInputTokensValue,
|
||||
effectiveTokens: formatCompactNumber(effectiveTokensValue),
|
||||
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
|
||||
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
|
||||
cacheHitRateValue,
|
||||
tokensValue: inputTokensValue + outputTokensValue,
|
||||
errors: formatCompactNumber(errorsValue),
|
||||
errorsValue,
|
||||
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
|
||||
successRateValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
|
||||
return points.reduce((total, point) => total + selector(point), 0)
|
||||
}
|
||||
|
||||
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
|
||||
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
|
||||
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
|
||||
const cachedTokens = Math.min(
|
||||
inputTokens,
|
||||
points.reduce((total, point) => total + point.cachedTokensValue, 0),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '输入',
|
||||
value: formatCompactNumber(inputTokens),
|
||||
tone: 'info',
|
||||
colorVar: '--cp-info',
|
||||
},
|
||||
{
|
||||
label: '输出',
|
||||
value: formatCompactNumber(outputTokens),
|
||||
tone: 'success',
|
||||
colorVar: '--cp-success',
|
||||
},
|
||||
{
|
||||
label: '缓存',
|
||||
value: formatCompactNumber(cachedTokens),
|
||||
tone: 'normal',
|
||||
colorVar: '--cp-text-tertiary',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function accountUsageItem(item: DashboardSummary['accountUsage'][number]) {
|
||||
const usageWindow = dashboardUsageWindow(item)
|
||||
const requestCount = usageWindow.localUsage?.requestCount
|
||||
const usesDailyRequests = typeof requestCount === 'number'
|
||||
return {
|
||||
id: item.id,
|
||||
provider: item.provider,
|
||||
authenticationKind: item.authenticationKind,
|
||||
email: item.email,
|
||||
planType: item.planType,
|
||||
tokens: item.tokens,
|
||||
lastUsed: item.lastUsed,
|
||||
metricLabel: usesDailyRequests ? '次数' : '今日 Token',
|
||||
metricValue: usesDailyRequests
|
||||
? requestCount > 0 ? usageWindow.localUsage?.requestCountDisplay : '—'
|
||||
: item.tokens,
|
||||
usageWindow,
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
|
||||
const provider = item.provider.trim().toLowerCase()
|
||||
const planType = item.planType?.trim().toLowerCase() || 'free'
|
||||
if (provider !== 'xai' || planType !== 'free') {
|
||||
const usedPercent = quotaUsedPercent(item.quotaUsedPercent)
|
||||
return {
|
||||
key: 'quota',
|
||||
group: 'other',
|
||||
labelDisplay: '额度',
|
||||
usedPercent,
|
||||
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
|
||||
limitReached: (usedPercent ?? 0) >= 100,
|
||||
windowSeconds: null,
|
||||
resetAtDisplay: '—',
|
||||
}
|
||||
}
|
||||
|
||||
const requestCount = typeof item.requestCount === 'number' && Number.isFinite(item.requestCount)
|
||||
? Math.max(0, item.requestCount)
|
||||
: 0
|
||||
const requestBuckets = Array.isArray(item.requestBuckets)
|
||||
? item.requestBuckets.map((bucket: { bucketStart: string, requestCount: number }) => ({
|
||||
bucketStart: bucket.bucketStart,
|
||||
requestCount: bucket.requestCount,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
key: 'dailyRequests',
|
||||
group: 'other',
|
||||
labelDisplay: '日请求',
|
||||
usedPercent: null,
|
||||
usedPercentDisplay: '—',
|
||||
limitReached: false,
|
||||
windowSeconds: 86_400,
|
||||
resetAtDisplay: '—',
|
||||
localUsage: {
|
||||
requestCount,
|
||||
requestCountDisplay: formatCompactNumber(requestCount),
|
||||
requestBuckets,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function quotaUsedPercent(value: number | null) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value))
|
||||
return null
|
||||
return clamp(Math.round(value), 0, 100)
|
||||
}
|
||||
|
||||
function formatDashboardUsd(value: string) {
|
||||
const normalized = value.trim()
|
||||
if (!normalized.startsWith('$'))
|
||||
return value
|
||||
const amount = Number(normalized.slice(1).replaceAll(',', ''))
|
||||
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : value
|
||||
}
|
||||
|
||||
function trendState(
|
||||
current: number,
|
||||
previous: number,
|
||||
fallbackTone: MetricTone,
|
||||
): MetricCardView['trend'] {
|
||||
if (current > previous)
|
||||
return { direction: 'up', tone: 'success' }
|
||||
if (current < previous)
|
||||
return { direction: 'down', tone: 'danger' }
|
||||
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
|
||||
}
|
||||
|
||||
function trendSummaryTone(label: string) {
|
||||
if (label.includes('错误'))
|
||||
return 'danger'
|
||||
if (label.includes('最高'))
|
||||
return 'warning'
|
||||
if (label.includes('输出') || label.includes('最低') || label.includes('成功'))
|
||||
return 'success'
|
||||
if (label.includes('缓存'))
|
||||
return 'normal'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
|
||||
if (kind === 'latency') {
|
||||
if (label.includes('最高'))
|
||||
return '--cp-warning'
|
||||
if (label.includes('最低'))
|
||||
return '--cp-success'
|
||||
return '--cp-normal'
|
||||
}
|
||||
if (kind === 'errors') {
|
||||
if (label.includes('错误'))
|
||||
return '--cp-danger'
|
||||
if (label.includes('成功'))
|
||||
return '--cp-success'
|
||||
return '--cp-info'
|
||||
}
|
||||
if (label.includes('输出'))
|
||||
return '--cp-success'
|
||||
if (label.includes('缓存'))
|
||||
return '--cp-text-tertiary'
|
||||
return '--cp-info'
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
|
||||
import type { Ref } from 'vue'
|
||||
import type { dashboardTrendView, normalizeDashboardTrendKind } from './presenter'
|
||||
import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashboard'
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { tooltipIndex } from '@/components/charts/tooltip'
|
||||
import { useThemeColor } from '@/composables/useThemeColor'
|
||||
import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
|
||||
type TrendKind = ReturnType<typeof normalizeDashboardTrendKind>
|
||||
type TrendView = ReturnType<typeof dashboardTrendView>
|
||||
@@ -51,7 +51,7 @@ export function useRequestTrendChart(options: {
|
||||
onTrendChange: (kind: TrendKind) => void
|
||||
}) {
|
||||
const { points, summary, activeKind } = options
|
||||
const themeColor = useThemeColor()
|
||||
const { color: themeColor, palette } = useChartPalette()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const pinnedSummaryLabel = shallowRef<string>()
|
||||
|
||||
@@ -84,34 +84,21 @@ export function useRequestTrendChart(options: {
|
||||
animationEasingUpdate: 'cubicOut',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: [10, 14],
|
||||
textStyle: {
|
||||
color: themeColor('--cp-text-primary', '#334155'),
|
||||
fontSize: 12,
|
||||
...chartTooltipStyle(palette.value, {
|
||||
axisPointer: true,
|
||||
confine: true,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
color: themeColor('--cp-default-border-hover', '#E2E8F0'),
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
}),
|
||||
formatter: formatTooltip,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function getCoordinateSystem(times: string[]) {
|
||||
const muted = themeColor('--cp-text-muted', '#94A3B8')
|
||||
const gridLine = themeColor('--cp-divider-subtle', '#E2E8F0')
|
||||
const axisLine = themeColor('--cp-default-border', '#E2E8F0')
|
||||
const muted = palette.value.textMuted
|
||||
const gridLine = palette.value.divider
|
||||
const axisLine = palette.value.border
|
||||
return {
|
||||
grid: {
|
||||
left: 8,
|
||||
@@ -207,7 +194,7 @@ export function useRequestTrendChart(options: {
|
||||
trendPointText(point, 'cacheHitRate'),
|
||||
trendColor('缓存', '--cp-text-tertiary', '#94A3B8'),
|
||||
),
|
||||
tooltipItem('请求', point?.requests, themeColor('--cp-text-secondary', '#64748B')),
|
||||
tooltipItem('请求', point?.requests, palette.value.textSecondary),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MetricTone } from './composables/presenter'
|
||||
import type { MetricTone } from './composables/useDashboard'
|
||||
import { formatInteger } from '@/utils/number'
|
||||
|
||||
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
|
||||
export const metricToneIconClasses: Record<MetricTone, string> = {
|
||||
@@ -105,5 +106,5 @@ export function healthReliabilityValueClass(successRequests: number, failedReque
|
||||
}
|
||||
|
||||
export function formatHealthCount(value: number) {
|
||||
return Math.max(0, value).toLocaleString('zh-CN')
|
||||
return formatInteger(Math.max(0, value))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { getOpsErrors } from '@/api'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { displayValue } from '../utils/detail'
|
||||
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
|
||||
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -37,10 +38,6 @@ const metadataText = computed(() => {
|
||||
return JSON.stringify(metadata, null, 2)
|
||||
})
|
||||
|
||||
function display(value: unknown) {
|
||||
return value === undefined || value === null || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function latencyDisplay(value: unknown) {
|
||||
return typeof value === 'number' ? `${value} ms` : '—'
|
||||
}
|
||||
@@ -67,14 +64,14 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<UsageStatusCodeBadge :status-code="record.clientStatusCode" />
|
||||
<span class="text-[12px] font-bold text-cp-secondary">
|
||||
客户端 {{ display(record.clientStatusCode) }}
|
||||
客户端 {{ displayValue(record.clientStatusCode) }}
|
||||
</span>
|
||||
<span class="text-[12px] font-bold text-cp-secondary">
|
||||
上游 {{ display(record.upstreamStatusCode) }}
|
||||
上游 {{ displayValue(record.upstreamStatusCode) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 mb-0 text-[13px] leading-relaxed font-bold text-cp-primary">
|
||||
{{ display(record.message) }}
|
||||
{{ displayValue(record.message) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -92,9 +89,9 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-normal font-emphasis text-cp-primary"
|
||||
:title="display(field.value)"
|
||||
:title="displayValue(field.value)"
|
||||
>
|
||||
{{ display(field.value) }}
|
||||
{{ displayValue(field.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -103,20 +100,7 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
v-if="metadataText"
|
||||
class="mt-3 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
|
||||
>
|
||||
<h3 class="m-0 text-[12px] leading-none font-heavy text-cp-secondary">
|
||||
Metadata
|
||||
</h3>
|
||||
<BaseScrollbar
|
||||
class="mt-3"
|
||||
max-height="260px"
|
||||
>
|
||||
<div class="rounded-cp-control bg-cp-surface px-3 py-2.5">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-cp-primary"
|
||||
v-text="metadataText"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
<UsageDetailCodePanel title="Metadata" max-height="260px" :content="metadataText" />
|
||||
</section>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -7,27 +7,28 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
|
||||
|
||||
import {
|
||||
tooltipIndex,
|
||||
tooltipRows,
|
||||
usageCategoryAxis,
|
||||
usageLegend,
|
||||
usageLineSeries,
|
||||
usageTooltip,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
import {
|
||||
decimalDisplayNumber,
|
||||
escapeTooltip,
|
||||
formatCompactNumber,
|
||||
formatPercent,
|
||||
formatUsd,
|
||||
formatUsdAxis,
|
||||
} from '../utils/format'
|
||||
|
||||
type Cost = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['cost']
|
||||
type UsageChartPalette = ReturnType<typeof useUsageChartPalette>['palette']['value']
|
||||
type UsageChartPalette = ReturnType<typeof useChartPalette>['palette']['value']
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -40,7 +41,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const activeView = shallowRef('cost')
|
||||
const { palette } = useUsageChartPalette()
|
||||
const { palette } = useChartPalette()
|
||||
const points = computed(() => props.cost.points)
|
||||
const hasStandardCost = computed(() =>
|
||||
points.value.some(point => point.standardCost != null),
|
||||
@@ -107,35 +108,35 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
const chartPoints = points.value
|
||||
if (activeView.value === 'tokens') {
|
||||
return [
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'未缓存输入',
|
||||
chartPoints.map(point => Math.max(0, point.inputTokens - point.cachedTokens)),
|
||||
theme.info,
|
||||
'tokens',
|
||||
{ stack: 'tokens' },
|
||||
),
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'缓存输入',
|
||||
chartPoints.map(point => point.cachedTokens),
|
||||
theme.normal,
|
||||
'tokens',
|
||||
{ stack: 'tokens' },
|
||||
),
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'输出',
|
||||
chartPoints.map(point => point.outputTokens),
|
||||
theme.success,
|
||||
'tokens',
|
||||
{ stack: 'tokens' },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
if (activeView.value === 'cache') {
|
||||
return [
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'缓存 Token 占比',
|
||||
chartPoints.map(point => point.cachedTokenRate),
|
||||
theme.normal,
|
||||
),
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'命中请求率',
|
||||
chartPoints.map(point => point.cacheHitRequestRate),
|
||||
theme.success,
|
||||
@@ -144,17 +145,16 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
}
|
||||
|
||||
const series = [
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'预估费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
|
||||
theme.success,
|
||||
undefined,
|
||||
true,
|
||||
{ area: 'strong' },
|
||||
),
|
||||
]
|
||||
if (hasStandardCost.value) {
|
||||
series.push(
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
'标准费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.standardCost)),
|
||||
theme.textMuted,
|
||||
@@ -164,43 +164,6 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
return series
|
||||
}
|
||||
|
||||
function lineSeries(
|
||||
name: string,
|
||||
data: Array<number | null>,
|
||||
color: string,
|
||||
stack?: string,
|
||||
area = Boolean(stack),
|
||||
): LineSeriesOption {
|
||||
return {
|
||||
name,
|
||||
type: 'line',
|
||||
data: data.map(value => value ?? null),
|
||||
connectNulls: false,
|
||||
stack,
|
||||
smooth: true,
|
||||
showSymbol: data.length <= 12,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color, width: 2.2 },
|
||||
itemStyle: { color },
|
||||
areaStyle: area
|
||||
? {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${color}30` },
|
||||
{ offset: 1, color: `${color}08` },
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatTooltip(params: unknown) {
|
||||
const rows = tooltipRows(params)
|
||||
const point = points.value[tooltipIndex(rows[0])]
|
||||
|
||||
@@ -8,8 +8,9 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
|
||||
|
||||
import { formatCompactNumber, formatDuration, formatPercent, formatUsd } from '../utils/format'
|
||||
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
|
||||
|
||||
type Diagnostics = Awaited<ReturnType<typeof getUsageRecordInsightsDiagnostics>>
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { computed } from 'vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
|
||||
|
||||
import {
|
||||
tooltipIndex,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
usageTooltip,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
import { escapeTooltip, formatCompactNumber, formatPercent } from '../utils/format'
|
||||
import { escapeTooltip, formatPercent } from '../utils/format'
|
||||
|
||||
type Health = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']
|
||||
type HealthPoint = Health['points'][number]
|
||||
@@ -36,7 +37,7 @@ const props = withDefaults(
|
||||
|
||||
use([BarChart])
|
||||
|
||||
const { palette } = useUsageChartPalette()
|
||||
const { palette } = useChartPalette()
|
||||
const points = computed<HealthPoint[]>(() => props.health.points)
|
||||
|
||||
const hasData = computed(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption, LineSeriesOption } from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { getUsageRecordInsightsOverview } from '@/api'
|
||||
|
||||
import { computed, shallowRef } from 'vue'
|
||||
@@ -7,13 +7,14 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
|
||||
import {
|
||||
tooltipIndex,
|
||||
tooltipRows,
|
||||
usageCategoryAxis,
|
||||
usageLegend,
|
||||
usageLineSeries,
|
||||
usageTooltip,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
@@ -33,7 +34,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const activeView = shallowRef('total')
|
||||
const { palette } = useUsageChartPalette()
|
||||
const { palette } = useChartPalette()
|
||||
const performancePoints = computed<PerformancePoint[]>(() => props.performance.points)
|
||||
|
||||
const viewOptions = [
|
||||
@@ -79,18 +80,18 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
),
|
||||
yAxis: usageValueAxis(theme, formatDurationAxis),
|
||||
series: [
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
percentileLabels.p50,
|
||||
points.map(point => point.p50),
|
||||
theme.info,
|
||||
true,
|
||||
{ area: 'subtle' },
|
||||
),
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
percentileLabels.p95,
|
||||
points.map(point => point.p95),
|
||||
theme.warning,
|
||||
),
|
||||
lineSeries(
|
||||
usageLineSeries(
|
||||
percentileLabels.p99,
|
||||
points.map(point => point.p99),
|
||||
theme.danger,
|
||||
@@ -114,41 +115,6 @@ function percentileValue(point: PerformancePoint, percentile: 'p50' | 'p95' | 'p
|
||||
return point.latencyP99Ms
|
||||
}
|
||||
|
||||
function lineSeries(
|
||||
name: string,
|
||||
data: Array<number | null | undefined>,
|
||||
color: string,
|
||||
area = false,
|
||||
): LineSeriesOption {
|
||||
return {
|
||||
name,
|
||||
type: 'line',
|
||||
data: data.map(value => value ?? null),
|
||||
connectNulls: false,
|
||||
smooth: true,
|
||||
showSymbol: data.length <= 12,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color, width: 2.2 },
|
||||
itemStyle: { color },
|
||||
areaStyle: area
|
||||
? {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${color}18` },
|
||||
{ offset: 1, color: `${color}02` },
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatTooltip(params: unknown) {
|
||||
const rows = tooltipRows(params)
|
||||
const selected = selectedPoints.value[tooltipIndex(rows[0])]
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { UsageViewModel } from '@/api'
|
||||
import type { UsageViewModel } from '../utils/records'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import { useThemeColor } from '@/composables/useThemeColor'
|
||||
import { chartTooltipStyle } from '@/components/charts/tooltip'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { displayValue, fieldLabelClass, fieldValueBaseClass, fieldValueClass } from '../utils/detail'
|
||||
import { formatDuration } from '../utils/format'
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ const props = defineProps<{
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const themeColor = useThemeColor()
|
||||
const { palette } = useChartPalette()
|
||||
|
||||
const requestText = computed(() => props.record ? visibleRequestText(props.record) : '')
|
||||
const responseText = computed(() => props.record ? visibleResponseText(props.record) : '')
|
||||
@@ -198,31 +199,31 @@ const tokenChartItems = computed(() => [
|
||||
label: '输入',
|
||||
value: Number(tokenDetails.value?.inputTokens || 0),
|
||||
display: tokenDetails.value?.inputTokensDisplay ?? '—',
|
||||
color: themeColor('--cp-info', '#2563EB'),
|
||||
color: palette.value.info,
|
||||
},
|
||||
{
|
||||
label: '输出',
|
||||
value: Number(tokenDetails.value?.outputTokens || 0),
|
||||
display: tokenDetails.value?.outputTokensDisplay ?? '—',
|
||||
color: themeColor('--cp-success', '#10B981'),
|
||||
color: palette.value.success,
|
||||
},
|
||||
{
|
||||
label: '缓存读取',
|
||||
value: Number(tokenDetails.value?.cachedTokens || 0),
|
||||
display: tokenDetails.value?.cachedTokensDisplay ?? '—',
|
||||
color: themeColor('--cp-warning', '#D97706'),
|
||||
color: palette.value.warning,
|
||||
},
|
||||
{
|
||||
label: '缓存写入',
|
||||
value: Number(tokenDetails.value?.cacheWriteTokens || 0),
|
||||
display: tokenDetails.value?.cacheWriteTokensDisplay ?? '—',
|
||||
color: themeColor('--cp-danger', '#DC2626'),
|
||||
color: palette.value.danger,
|
||||
},
|
||||
{
|
||||
label: '推理',
|
||||
value: Number(tokenDetails.value?.reasoningTokens || 0),
|
||||
display: tokenDetails.value?.reasoningTokensDisplay ?? '—',
|
||||
color: themeColor('--cp-reasoning', '#8B5CF6'),
|
||||
color: palette.value.reasoning,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -232,17 +233,7 @@ const tokenDonutOption = computed<EChartsOption>(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: [9, 12],
|
||||
textStyle: {
|
||||
color: themeColor('--cp-text-primary', '#334155'),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
|
||||
fontWeight: 650,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
...chartTooltipStyle(palette.value, { padding: [9, 12] }),
|
||||
formatter: (params: unknown) => {
|
||||
if (typeof params !== 'object' || params === null)
|
||||
return ''
|
||||
@@ -275,7 +266,7 @@ const tokenDonutOption = computed<EChartsOption>(() => {
|
||||
{
|
||||
name: '暂无',
|
||||
value: 1,
|
||||
itemStyle: { color: themeColor('--cp-bg-muted', '#E5E7EB') },
|
||||
itemStyle: { color: palette.value.surfaceMuted },
|
||||
},
|
||||
],
|
||||
emphasis: { scale: false },
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useThemeColor } from '@/composables/useThemeColor'
|
||||
|
||||
export function useUsageChartPalette() {
|
||||
const themeColor = useThemeColor()
|
||||
|
||||
const palette = computed(() => ({
|
||||
textPrimary: themeColor('--cp-text-primary', '#0E1726'),
|
||||
textSecondary: themeColor('--cp-text-secondary', '#64748B'),
|
||||
textMuted: themeColor('--cp-text-muted', '#94A3B8'),
|
||||
surface: themeColor('--cp-bg-surface', '#FFFFFF'),
|
||||
grid: themeColor('--cp-bg-muted', '#F1F5F9'),
|
||||
pointer: themeColor('--cp-default-border-hover', '#CBD5E1'),
|
||||
info: themeColor('--cp-info', '#2563EB'),
|
||||
success: themeColor('--cp-success', '#12B981'),
|
||||
warning: themeColor('--cp-warning', '#F59E0B'),
|
||||
danger: themeColor('--cp-danger', '#EF4444'),
|
||||
normal: themeColor('--cp-normal', '#0F9F9A'),
|
||||
}))
|
||||
|
||||
return { palette }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user