From 806f7ad9ee8f794e4c0d9a2c808747bfb49ca2f7 Mon Sep 17 00:00:00 2001 From: Zyy <571092077@qq.com> Date: Fri, 23 Oct 2026 20:32:01 +0800 Subject: [PATCH] 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 --- .../src/admin/accounts/presenter.rs | 3 + .../gateway-api/src/admin/accounts/wire.rs | 1 + .../src/admin/observability/presenter.rs | 3 + .../src/admin/observability/wire.rs | 1 + backend/crates/gateway-api/src/time.rs | 60 +++++++- .../tests/admin/accounts/handlers.rs | 84 +++++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 26 +++- .../tests/admin/observability/response.rs | 141 ++++++++++++++++++ docs/api.md | 17 ++- frontend/src/api/modules/accounts.ts | 2 + frontend/src/api/modules/dashboard.ts | 2 + frontend/src/api/time.ts | 4 + frontend/src/i18n/locales/en/common.json | 5 +- frontend/src/i18n/locales/zh-Hans/common.json | 5 +- frontend/src/i18n/locales/zh-Hant/common.json | 5 +- frontend/src/utils/relative-time.ts | 17 +++ .../components/account-quota-panel/index.vue | 12 +- .../components/AccountOverviewCard.vue | 5 +- .../src/views/theme/fixtures/dashboard.ts | 9 +- 19 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 frontend/src/api/time.ts create mode 100644 frontend/src/utils/relative-time.ts diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 130f9c719..05229ab37 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -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, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 24c6c94b0..4a8b2114b 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -386,6 +386,7 @@ pub use crate::admin::account_groups::AccountGroupRefView; #[serde(rename_all = "camelCase")] pub struct AccountQuotaView { pub refreshed_at: Option, + pub refreshed_at_relative: Option, pub refreshed_at_display: String, pub limit_reached: bool, /// 冷却到期或可开始恢复探测的时间;非限流中为 `null` diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 7347e53c5..2d27d016b 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -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), }); } diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 1980b946b..e518755fc 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -429,6 +429,7 @@ pub struct DashboardAccountUsageView { pub metric_label: String, pub metric_value: String, pub last_used_at: Option>, + pub last_used_relative: Option, pub last_used: String, } diff --git a/backend/crates/gateway-api/src/time.rs b/backend/crates/gateway-api/src/time.rs index 574447cd2..182fb931e 100644 --- a/backend/crates/gateway-api/src/time.rs +++ b/backend/crates/gateway-api/src/time.rs @@ -101,21 +101,51 @@ impl TimePresenter { self.timezone.local(*value).to_rfc3339() } - pub(crate) fn relative(self, value: DateTime, now: DateTime) -> String { + pub(crate) fn relative_view( + self, + value: DateTime, + now: DateTime, + ) -> 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, now: DateTime) -> 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 { diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 9933f07cf..8afb5a4de 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -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::() + .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::() + .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, diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 181872666..796ea6624 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -112,6 +112,7 @@ pub(super) struct AdminTestFixture { pub dashboard_observation: Arc>>, pub dashboard_summary_range: Arc>>, pub provider_error: Arc>>, + pub provider_quota: Arc>>, pub account: Arc>>, pub account_usage: Arc>>, } @@ -187,9 +188,18 @@ impl AdminTestFixture { plugin_ports.clone(), plugin_ports.clone(), ); + let provider_quota = Arc::new(Mutex::new(None)); let providers: Vec> = 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>>, + quota: Arc>>, } impl UnusedProvider { - fn new(kind: &str, error: Arc>>) -> Self { + fn new( + kind: &str, + error: Arc>>, + quota: Arc>>, + ) -> 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 { + if let Some(quota) = self.quota.lock().expect("provider quota").clone() { + return Ok(quota); + } Err(self .error .lock() diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 1616cb694..64a8a6d6f 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -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::>().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::() + .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)); + } + } +} diff --git a/docs/api.md b/docs/api.md index 7d549306f..55797f414 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` 保留给兼容调用方,当前管理端不解析或显示这些字段 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index e2c21fa86..040350fda 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -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。 diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts index b83bafb80..a395e90d8 100644 --- a/frontend/src/api/modules/dashboard.ts +++ b/frontend/src/api/modules/dashboard.ts @@ -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 } diff --git a/frontend/src/api/time.ts b/frontend/src/api/time.ts new file mode 100644 index 000000000..cd94215e0 --- /dev/null +++ b/frontend/src/api/time.ts @@ -0,0 +1,4 @@ +export type RelativeTimeView + = { kind: 'just_now' } + | { kind: 'elapsed', value: number, unit: 'minute' | 'hour' | 'day' } + | { kind: 'absolute', value: string } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index b6dbb0854..e3ff7b403 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -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" diff --git a/frontend/src/i18n/locales/zh-Hans/common.json b/frontend/src/i18n/locales/zh-Hans/common.json index e7cfc6973..e87fe8ddf 100644 --- a/frontend/src/i18n/locales/zh-Hans/common.json +++ b/frontend/src/i18n/locales/zh-Hans/common.json @@ -126,7 +126,10 @@ "copied": "已复制" }, "time": { - "minutesAgo": "{count} 分钟前" + "justNow": "刚刚", + "minutesAgo": "{count} 分钟前", + "hoursAgo": "{count} 小时前", + "daysAgo": "{count} 天前" }, "units": { "tokens": "Token" diff --git a/frontend/src/i18n/locales/zh-Hant/common.json b/frontend/src/i18n/locales/zh-Hant/common.json index 51c66d38b..ec9bde11c 100644 --- a/frontend/src/i18n/locales/zh-Hant/common.json +++ b/frontend/src/i18n/locales/zh-Hant/common.json @@ -126,7 +126,10 @@ "copied": "已複製" }, "time": { - "minutesAgo": "{count} 分鐘前" + "justNow": "剛剛", + "minutesAgo": "{count} 分鐘前", + "hoursAgo": "{count} 小時前", + "daysAgo": "{count} 天前" }, "units": { "tokens": "Token" diff --git a/frontend/src/utils/relative-time.ts b/frontend/src/utils/relative-time.ts new file mode 100644 index 000000000..cbbb65aba --- /dev/null +++ b/frontend/src/utils/relative-time.ts @@ -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) + } +} diff --git a/frontend/src/views/accounts/components/account-quota-panel/index.vue b/frontend/src/views/accounts/components/account-quota-panel/index.vue index dad15460a..7d8de080f 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/index.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/index.vue @@ -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) => { · - {{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: account.quota.refreshedAt ? refreshedAtDisplay : '—' }) }} + {{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: formatRelativeTime(account.quota.refreshedAtRelative) }) }}

diff --git a/frontend/src/views/dashboard/components/AccountOverviewCard.vue b/frontend/src/views/dashboard/components/AccountOverviewCard.vue index 64c8e9322..eb4ae63c6 100644 --- a/frontend/src/views/dashboard/components/AccountOverviewCard.vue +++ b/frontend/src/views/dashboard/components/AccountOverviewCard.vue @@ -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(() => { - - + {{ formatRelativeTime(account.lastUsedRelative) }} diff --git a/frontend/src/views/theme/fixtures/dashboard.ts b/frontend/src/views/theme/fixtures/dashboard.ts index 3876d7008..472530e81 100644 --- a/frontend/src/views/theme/fixtures/dashboard.ts +++ b/frontend/src/views/theme/fixtures/dashboard.ts @@ -354,7 +354,8 @@ export const themeDashboardSummary = computed(() => ({ 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(() => ({ }, }, 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(() => ({ 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: [