mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(i18n): preserve server-authoritative relative times
Keep dashboard and quota relative-time thresholds and future timestamps owned by the backend deployment timezone. Localize only the structured presentation in the frontend. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
Zyy
co-authored by
Codex
parent
632edee7dc
commit
806f7ad9ee
@@ -242,6 +242,9 @@ pub(super) fn account_quota_view(
|
||||
(
|
||||
AccountQuotaView {
|
||||
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
|
||||
refreshed_at_relative: quota
|
||||
.observed_at
|
||||
.map(|value| time.relative_view(value, now)),
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
|
||||
@@ -386,6 +386,7 @@ pub use crate::admin::account_groups::AccountGroupRefView;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub refreshed_at: Option<String>,
|
||||
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub refreshed_at_display: String,
|
||||
pub limit_reached: bool,
|
||||
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
|
||||
|
||||
@@ -980,6 +980,9 @@ pub(crate) fn dashboard_view(
|
||||
metric_label,
|
||||
metric_value,
|
||||
last_used_at: credential.last_used_at,
|
||||
last_used_relative: credential
|
||||
.last_used_at
|
||||
.map(|value| time.relative_view(value, range.end)),
|
||||
last_used: time.relative_optional(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -429,6 +429,7 @@ pub struct DashboardAccountUsageView {
|
||||
pub metric_label: String,
|
||||
pub metric_value: String,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub last_used_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -101,21 +101,51 @@ impl TimePresenter {
|
||||
self.timezone.local(*value).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
pub(crate) fn relative_view(
|
||||
self,
|
||||
value: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> RelativeTimeView {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return self.datetime(&value);
|
||||
return RelativeTimeView::Absolute {
|
||||
value: self.datetime(&value),
|
||||
};
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
return RelativeTimeView::JustNow;
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_minutes(),
|
||||
unit: RelativeTimeUnit::Minute,
|
||||
};
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_hours(),
|
||||
unit: RelativeTimeUnit::Hour,
|
||||
};
|
||||
}
|
||||
RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_days(),
|
||||
unit: RelativeTimeUnit::Day,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
match self.relative_view(value, now) {
|
||||
RelativeTimeView::JustNow => "刚刚".to_owned(),
|
||||
RelativeTimeView::Elapsed { value, unit } => {
|
||||
let unit = match unit {
|
||||
RelativeTimeUnit::Minute => "分钟",
|
||||
RelativeTimeUnit::Hour => "小时",
|
||||
RelativeTimeUnit::Day => "天",
|
||||
};
|
||||
format!("{value} {unit}前")
|
||||
}
|
||||
RelativeTimeView::Absolute { value } => value,
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(crate) fn relative_optional(
|
||||
@@ -127,6 +157,24 @@ impl TimePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端按查询锚点与部署时区决定展示方式,前端只本地化分类和单位
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RelativeTimeView {
|
||||
JustNow,
|
||||
Elapsed { value: i64, unit: RelativeTimeUnit },
|
||||
Absolute { value: String },
|
||||
}
|
||||
|
||||
/// 相对时间保持分钟、小时、天的固定精度,不引入月或年的日历推算
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelativeTimeUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestBucketView {
|
||||
|
||||
@@ -160,6 +160,90 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::provider_credentials::ProviderQuota;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
|
||||
use serde_json::json;
|
||||
|
||||
for zone in ["UTC", "Asia/Shanghai", "Asia/Kathmandu", "America/New_York"] {
|
||||
let zone = zone
|
||||
.parse::<gateway_core::time::DeploymentTimeZone>()
|
||||
.unwrap();
|
||||
let fixture = AdminTestFixture::with_timezone(zone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
now,
|
||||
"oauth",
|
||||
));
|
||||
let future = now + Duration::days(1);
|
||||
let future_display = zone.local(future).format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
for (observed_at, expected, legacy) in [
|
||||
(None, json!(null), "—"),
|
||||
(Some(now), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(
|
||||
Some(now - Duration::seconds(90)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(now - Duration::hours(25)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
|
||||
"1 天前",
|
||||
),
|
||||
(
|
||||
Some(future),
|
||||
json!({ "kind": "absolute", "value": future_display }),
|
||||
future_display.as_str(),
|
||||
),
|
||||
] {
|
||||
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
credits: None,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
});
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_quota_relative_time")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::ACCEPT_LANGUAGE, "en")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let quota = &value["data"]["items"][0]["quota"];
|
||||
assert_eq!(quota["refreshedAtRelative"], expected);
|
||||
assert_eq!(quota["refreshedAtDisplay"], legacy);
|
||||
let refreshed_at = quota["refreshedAt"]
|
||||
.as_str()
|
||||
.map(|value| DateTime::parse_from_rfc3339(value).unwrap().to_utc());
|
||||
assert_eq!(refreshed_at, observed_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_page_item(
|
||||
facts: gateway_core::account::AccountStatusFacts,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
pub account: Arc<Mutex<Option<AccountPageItem>>>,
|
||||
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
|
||||
}
|
||||
@@ -187,9 +188,18 @@ impl AdminTestFixture {
|
||||
plugin_ports.clone(),
|
||||
plugin_ports.clone(),
|
||||
);
|
||||
let provider_quota = Arc::new(Mutex::new(None));
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
|
||||
Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))),
|
||||
Arc::new(UnusedProvider::new(
|
||||
"openai",
|
||||
Arc::clone(&provider_error),
|
||||
Arc::clone(&provider_quota),
|
||||
)),
|
||||
Arc::new(UnusedProvider::new(
|
||||
"xai",
|
||||
Arc::clone(&provider_error),
|
||||
Arc::clone(&provider_quota),
|
||||
)),
|
||||
];
|
||||
let bundle = gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
@@ -236,6 +246,7 @@ impl AdminTestFixture {
|
||||
dashboard_observation,
|
||||
dashboard_summary_range,
|
||||
provider_error,
|
||||
provider_quota,
|
||||
account,
|
||||
account_usage,
|
||||
}
|
||||
@@ -1590,13 +1601,19 @@ impl ObservabilityStore for UnusedStore {
|
||||
struct UnusedProvider {
|
||||
kind: ProviderKind,
|
||||
error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
}
|
||||
|
||||
impl UnusedProvider {
|
||||
fn new(kind: &str, error: Arc<Mutex<Option<ProviderAdminError>>>) -> Self {
|
||||
fn new(
|
||||
kind: &str,
|
||||
error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
error,
|
||||
quota,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1691,6 +1708,9 @@ impl ProviderAdmin for UnusedProvider {
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
if let Some(quota) = self.quota.lock().expect("provider quota").clone() {
|
||||
return Ok(quota);
|
||||
}
|
||||
Err(self
|
||||
.error
|
||||
.lock()
|
||||
|
||||
@@ -1626,3 +1626,144 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_relative_time_preserves_server_anchor_boundaries_and_deployment_timezone() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{DateTime, Duration};
|
||||
use gateway_admin::model::observability::{
|
||||
DashboardAccountUsage, DashboardObservation, TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let now = "2026-06-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
|
||||
for (zone, future_display) in [
|
||||
("UTC", "2026-06-15 00:00:01"),
|
||||
("Asia/Shanghai", "2026-06-15 08:00:01"),
|
||||
("Asia/Kathmandu", "2026-06-15 05:45:01"),
|
||||
("America/New_York", "2026-06-14 20:00:01"),
|
||||
] {
|
||||
for (elapsed_ms, expected, legacy) in [
|
||||
(None, json!(null), "—"),
|
||||
(
|
||||
Some(-1_000),
|
||||
json!({ "kind": "absolute", "value": future_display }),
|
||||
future_display,
|
||||
),
|
||||
(Some(-1), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(Some(0), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(Some(59_999), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(
|
||||
Some(60_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(119_999),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(3_599_999),
|
||||
json!({ "kind": "elapsed", "value": 59, "unit": "minute" }),
|
||||
"59 分钟前",
|
||||
),
|
||||
(
|
||||
Some(3_600_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "hour" }),
|
||||
"1 小时前",
|
||||
),
|
||||
(
|
||||
Some(86_399_999),
|
||||
json!({ "kind": "elapsed", "value": 23, "unit": "hour" }),
|
||||
"23 小时前",
|
||||
),
|
||||
(
|
||||
Some(86_400_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
|
||||
"1 天前",
|
||||
),
|
||||
(
|
||||
Some(34_560_000_000),
|
||||
json!({ "kind": "elapsed", "value": 400, "unit": "day" }),
|
||||
"400 天前",
|
||||
),
|
||||
] {
|
||||
// 每个边界使用独立缓存,避免相同锚点复用上一轮观测数据
|
||||
let fixture = AdminTestFixture::with_timezone(zone.parse().unwrap()).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let last_used_at = elapsed_ms.map(|value| now - Duration::milliseconds(value));
|
||||
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
|
||||
yesterday: Default::default(),
|
||||
analytics: Default::default(),
|
||||
runtime_slots: None,
|
||||
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: Default::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: vec![DashboardAccountUsage {
|
||||
account_id: "acct_relative_time".to_owned(),
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
name: "Relative time fixture".to_owned(),
|
||||
email: None,
|
||||
plan_type: None,
|
||||
plan_type_display: None,
|
||||
request_count: 1,
|
||||
success_count: 1,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
cache_write_input_tokens: None,
|
||||
reasoning_output_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at,
|
||||
request_buckets: Vec::new(),
|
||||
quota_used_percent: None,
|
||||
quota_window: None,
|
||||
models: Vec::new(),
|
||||
}],
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/admin/dashboard/summary?asOf={}",
|
||||
now.timestamp_millis()
|
||||
))
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::ACCEPT_LANGUAGE, "en")
|
||||
.header("x-request-id", "req_relative_time")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{zone}: {value}");
|
||||
let account = &value["data"]["accountUsage"][0];
|
||||
assert_eq!(
|
||||
account["lastUsedRelative"], expected,
|
||||
"{zone}: {elapsed_ms:?}"
|
||||
);
|
||||
assert_eq!(account["lastUsed"], legacy, "{zone}: {elapsed_ms:?}");
|
||||
assert_eq!(account["lastUsedAt"], json!(last_used_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -158,10 +158,17 @@ Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`5020
|
||||
`YYYY-MM-DD HH:mm:ss`,短时间、相对时间和图表标签按对应视图返回;缺失时间的展示字段为 `null` 或占位文本。
|
||||
调用者直接显示文本,使用原始时间点进行排序、比较和输入,不把展示文本转回请求时间
|
||||
|
||||
账号、模型用量和 Key 列表以及 Dashboard 的最近使用时间由前端根据原始 RFC3339 `lastUsedAt`,
|
||||
通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新。账号额度的最近刷新时间
|
||||
同样使用 `quota.refreshedAt`,缺失时显示占位文本。悬停的完整日期仍使用后端
|
||||
`lastUsedAtFullDisplay`,不使用浏览器本地时区重算
|
||||
账号、模型用量和 Key 列表的最近使用时间由前端根据原始 RFC3339 时间点,通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新;悬停的完整日期仍使用后端 `lastUsedAtFullDisplay`,不使用浏览器本地时区重算
|
||||
|
||||
Dashboard 的 `lastUsedRelative` 和账号额度的 `refreshedAtRelative` 由后端确定时间语义,
|
||||
分别以查询锚点和服务端当前时间计算,随接口刷新更新。前端只翻译分类与单位,不使用浏览器时钟或时区重算
|
||||
|
||||
- 缺失时间返回 `null`,前端显示占位文本
|
||||
- `kind=just_now` 表示不足 60 秒
|
||||
- `kind=elapsed` 包含整数 `value` 和 `unit=minute|hour|day`,分钟不足 60、小时不足 24,之后始终用天;数值向下取整
|
||||
- `kind=absolute` 的 `value` 是后端按部署 `host.timezone` 生成的完整日期文本,用于按整秒判断的未来时间点
|
||||
|
||||
原始 RFC3339 `lastUsedAt`、`quota.refreshedAt` 与兼容展示字段保持可用,不用于前端重新决定上述展示方式
|
||||
|
||||
账号冷却返回 RFC3339 `rateLimitedUntil` 和 `rateLimitRecoveryDisplay`,后者包含剩余时长、
|
||||
到期时间或等待恢复探测的提示,随账号数据刷新;前端不解析展示文本或自行计算恢复提示
|
||||
@@ -2033,7 +2040,7 @@ Dashboard 的 `capacityInfo` 统计普通容量,不含独立审批池;`maxCo
|
||||
`usedSlots` 仍表示实际在途数,Redis 不可用时为 `null`;没有可用账号时 `totalSlots` 为 `0`
|
||||
|
||||
Dashboard 的 `accountUsage[]` 返回稳定指标 `metric=requests|tokens`、可空原始数值 `metricValueRaw` 和
|
||||
可空 RFC3339 `lastUsedAt`,前端负责指标名称、数字和相对时间的本地化。
|
||||
可空 RFC3339 `lastUsedAt`;前端负责指标名称和数字本地化,相对时间按[页面时间合同](#页面时间合同)翻译 `lastUsedRelative`。
|
||||
`requests` 使用代表性额度窗口的本地请求数,`tokens` 使用今日 Token;未知数值为 `null`,零值保留 `0`。
|
||||
`metricLabel`、`metricValue` 和 `lastUsed` 保留给兼容调用方,当前管理端不解析或显示这些字段
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestOptions } from '../request'
|
||||
import type { RelativeTimeView } from '../time'
|
||||
import type { AccountGroupRef } from './groups'
|
||||
import request from '../request'
|
||||
|
||||
@@ -36,6 +37,7 @@ export interface AccountQuotaCredits {
|
||||
|
||||
export interface AccountQuota {
|
||||
refreshedAt: string | null
|
||||
refreshedAtRelative: RelativeTimeView | null
|
||||
refreshedAtDisplay: string
|
||||
limitReached: boolean
|
||||
// 429 临时限流(Redis 冷却)到期时间;非限流中为 null。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestOptions } from '../request'
|
||||
import type { RelativeTimeView } from '../time'
|
||||
import type { AccountQuotaWindow } from './accounts'
|
||||
import type { AnalyticsMetadata } from './analytics'
|
||||
import type { UsageListRecord } from './usage'
|
||||
@@ -165,6 +166,7 @@ export interface DashboardAccountUsage {
|
||||
metricLabel: string
|
||||
metricValue: string
|
||||
lastUsedAt: string | null
|
||||
lastUsedRelative: RelativeTimeView | null
|
||||
lastUsed: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type RelativeTimeView
|
||||
= { kind: 'just_now' }
|
||||
| { kind: 'elapsed', value: number, unit: 'minute' | 'hour' | 'day' }
|
||||
| { kind: 'absolute', value: string }
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "Copied"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} minute ago | {count} minutes ago"
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{count} minute ago | {count} minutes ago",
|
||||
"hoursAgo": "{count} hour ago | {count} hours ago",
|
||||
"daysAgo": "{count} day ago | {count} days ago"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Tokens"
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "已复制"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} 分钟前"
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{count} 分钟前",
|
||||
"hoursAgo": "{count} 小时前",
|
||||
"daysAgo": "{count} 天前"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Token"
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "已複製"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} 分鐘前"
|
||||
"justNow": "剛剛",
|
||||
"minutesAgo": "{count} 分鐘前",
|
||||
"hoursAgo": "{count} 小時前",
|
||||
"daysAgo": "{count} 天前"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Token"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RelativeTimeView } from '@/api/time'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function formatRelativeTime(value: RelativeTimeView | null | undefined) {
|
||||
if (value == null)
|
||||
return '—'
|
||||
if (value.kind === 'absolute')
|
||||
return value.value
|
||||
if (value.kind === 'just_now')
|
||||
return t('common.time.justNow')
|
||||
|
||||
switch (value.unit) {
|
||||
case 'minute': return t('common.time.minutesAgo', { count: value.value }, value.value)
|
||||
case 'hour': return t('common.time.hoursAgo', { count: value.value }, value.value)
|
||||
case 'day': return t('common.time.daysAgo', { count: value.value }, value.value)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { Account } from '@/api'
|
||||
import { ZEmpty, ZIconButton } from '@codex-proxy/ui'
|
||||
|
||||
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
|
||||
import { useTimeAgoIntl } from '@vueuse/core'
|
||||
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
import { formatRelativeTime } from '@/utils/relative-time'
|
||||
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
|
||||
import AccountProfileModal from '../account-profile-modal/index.vue'
|
||||
import AccountQuotaForecastModal from '../account-quota-forecast-modal/index.vue'
|
||||
@@ -27,13 +27,7 @@ const emit = defineEmits<{
|
||||
quotaReset: [accountId: string]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n({ useScope: 'global' })
|
||||
|
||||
const refreshedAtDisplay = useTimeAgoIntl(() => props.account.quota.refreshedAt ?? 0, {
|
||||
get locale() { return locale.value },
|
||||
relativeTimeFormatOptions: { numeric: 'auto', style: 'short' },
|
||||
insertSpace: false,
|
||||
})
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
const quotaEntries = computed(() => groupedAccountQuotaWindows(
|
||||
orderedPanelQuotaWindows(props.account.quota.windows),
|
||||
@@ -66,7 +60,7 @@ watch(hasPersonalInfo, (available) => {
|
||||
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
|
||||
</template>
|
||||
<span>·</span>
|
||||
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: account.quota.refreshedAt ? refreshedAtDisplay : '—' }) }}</span>
|
||||
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: formatRelativeTime(account.quota.refreshedAtRelative) }) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
|
||||
|
||||
@@ -7,8 +7,8 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
|
||||
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { formatRelativeTime } from '@/utils/relative-time'
|
||||
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
|
||||
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
|
||||
|
||||
@@ -266,8 +266,7 @@ const statusBars = computed(() => {
|
||||
<span
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-text-secondary xl:whitespace-nowrap"
|
||||
>
|
||||
<LastUsedAtCell v-if="account.lastUsedAt" :value="account.lastUsedAt" :full-display="null" />
|
||||
<template v-else>—</template>
|
||||
{{ formatRelativeTime(account.lastUsedRelative) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -354,7 +354,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
resetAtDisplay: '—',
|
||||
},
|
||||
lastUsed: t('appearance.preview.components.justNow'),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
lastUsedAt: '2026-08-23T10:30:00+08:00',
|
||||
lastUsedRelative: { kind: 'just_now' },
|
||||
},
|
||||
{
|
||||
id: 'preview-xai-1',
|
||||
@@ -399,7 +400,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
},
|
||||
},
|
||||
lastUsed: t('common.time.minutesAgo', { count: 5 }),
|
||||
lastUsedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
lastUsedAt: '2026-08-23T10:25:00+08:00',
|
||||
lastUsedRelative: { kind: 'elapsed', value: 5, unit: 'minute' },
|
||||
},
|
||||
{
|
||||
id: 'preview-openai-2',
|
||||
@@ -431,7 +433,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
resetAtDisplay: '—',
|
||||
},
|
||||
lastUsed: t('common.time.minutesAgo', { count: 8 }),
|
||||
lastUsedAt: new Date(Date.now() - 8 * 60_000).toISOString(),
|
||||
lastUsedAt: '2026-08-23T10:22:00+08:00',
|
||||
lastUsedRelative: { kind: 'elapsed', value: 8, unit: 'minute' },
|
||||
},
|
||||
],
|
||||
usageRecords: [
|
||||
|
||||
Reference in New Issue
Block a user