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: [