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:
Zyy
2026-10-23 21:06:12 +08:00
committed by Zyy
co-authored by Codex
parent 632edee7dc
commit 806f7ad9ee
19 changed files with 370 additions and 32 deletions
@@ -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,
}
+54 -6
View File
@@ -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>,
+23 -3
View File
@@ -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
View File
@@ -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` 保留给兼容调用方,当前管理端不解析或显示这些字段
+2
View File
@@ -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。
+2
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
export type RelativeTimeView
= { kind: 'just_now' }
| { kind: 'elapsed', value: number, unit: 'minute' | 'hour' | 'day' }
| { kind: 'absolute', value: string }
+4 -1
View File
@@ -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"
+17
View File
@@ -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: [