From 6ae85cf715de4bace17b80c20a51ad1dfbe512fa Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 21 Feb 2026 17:17:52 +0800 Subject: [PATCH] feat(auth-files): add health badge and refresh status on cards --- .../authFiles/components/AuthFileCard.tsx | 89 ++++++++++++++++++- src/features/authFiles/constants.ts | 1 + src/i18n/locales/en.json | 8 ++ src/i18n/locales/ru.json | 8 ++ src/i18n/locales/zh-CN.json | 8 ++ src/pages/AuthFilesPage.module.scss | 63 +++++++++++++ src/types/authFile.ts | 4 + 7 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index f8f5f7e..6f311da 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -9,6 +9,7 @@ import { resolveAuthProvider } from '@/utils/quota'; import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; import { formatFileSize } from '@/utils/format'; import { + AUTH_FILE_REFRESH_WARNING_MS, QUOTA_PROVIDER_TYPES, formatModified, getTypeColor, @@ -23,6 +24,21 @@ import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFi import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection'; import styles from '@/pages/AuthFilesPage.module.scss'; +type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown'; + +const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']); +const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']); + +const parseDateFromUnknown = (value: unknown): Date | null => { + if (value === null || value === undefined || value === '') return null; + const asNumber = Number(value); + const date = + Number.isFinite(asNumber) && !Number.isNaN(asNumber) + ? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber) + : new Date(String(value)); + return Number.isNaN(date.getTime()) ? null : date; +}; + export type AuthFileCardProps = { file: AuthFileItem; selected: boolean; @@ -49,7 +65,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => { }; export function AuthFileCard(props: AuthFileCardProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { file, selected, @@ -93,6 +109,60 @@ export function AuthFileCard(props: AuthFileCardProps) { const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]); + const rawStatus = String(file.status ?? file['status'] ?? '') + .trim() + .toLowerCase(); + const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim(); + const normalizedStatusMessage = rawStatusMessage.toLowerCase(); + const isFileDisabled = file.disabled === true || rawStatus === 'disabled'; + const isUnavailable = file.unavailable === true || rawStatus === 'unavailable'; + const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh); + const isRefreshStale = + Boolean(lastRefreshDate) && + Date.now() - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS; + const hasStatusWarning = + Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage); + const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning'; + const healthStatus: AuthFileHealthStatus = isFileDisabled + ? 'disabled' + : hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale + ? 'warning' + : lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus) + ? 'healthy' + : 'unknown'; + const healthStatusClass = + healthStatus === 'healthy' + ? styles.healthStatusHealthy + : healthStatus === 'warning' + ? styles.healthStatusWarning + : healthStatus === 'disabled' + ? styles.healthStatusDisabled + : styles.healthStatusUnknown; + const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`); + const lastRefreshText = (() => { + if (!lastRefreshDate) return t('auth_files.refresh_not_available'); + + const diffMs = lastRefreshDate.getTime() - Date.now(); + const absMs = Math.abs(diffMs); + if (absMs < 30 * 1000) { + return t('auth_files.refresh_just_now'); + } + + const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [ + { unit: 'day', ms: 24 * 60 * 60 * 1000 }, + { unit: 'hour', ms: 60 * 60 * 1000 }, + { unit: 'minute', ms: 60 * 1000 }, + { unit: 'second', ms: 1000 } + ]; + const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1]; + const value = Math.round(diffMs / matched.ms); + const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }); + return formatter.format(value, matched.unit); + })(); + const lastRefreshTitle = lastRefreshDate + ? lastRefreshDate.toLocaleString(i18n.language) + : t('auth_files.refresh_not_available'); + const healthStatusTitle = rawStatusMessage || t('auth_files.refresh_not_available'); return (
+
+ + {t('auth_files.health_status_label')}: {healthStatusLabel} + + + {t('auth_files.last_refresh_label')}: {lastRefreshText} + +
+ {rawStatusMessage && hasStatusWarning && ( +
+ {rawStatusMessage} +
+ )} +
{t('stats.success')}: {fileStats.success} diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index f60c08b..12dac66 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -17,6 +17,7 @@ export const QUOTA_PROVIDER_TYPES = new Set(['antigravity', ' export const MIN_CARD_PAGE_SIZE = 3; export const MAX_CARD_PAGE_SIZE = 30; +export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000; export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/; export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dd68e02..c3d5e5c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -388,6 +388,14 @@ "search_empty_desc": "Try changing the filters or clearing the search box.", "file_size": "Size", "file_modified": "Modified", + "health_status_label": "Health", + "health_status_healthy": "Healthy", + "health_status_warning": "Warning", + "health_status_disabled": "Disabled", + "health_status_unknown": "Unknown", + "last_refresh_label": "Last Refresh", + "refresh_not_available": "N/A", + "refresh_just_now": "Just now", "download_button": "Download", "delete_button": "Delete", "delete_confirm": "Are you sure you want to delete file", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e7b9ff8..cba932a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -388,6 +388,14 @@ "search_empty_desc": "Попробуйте изменить фильтры или очистить строку поиска.", "file_size": "Размер", "file_modified": "Изменён", + "health_status_label": "Состояние", + "health_status_healthy": "Нормально", + "health_status_warning": "Предупреждение", + "health_status_disabled": "Отключено", + "health_status_unknown": "Неизвестно", + "last_refresh_label": "Последнее обновление", + "refresh_not_available": "Н/Д", + "refresh_just_now": "Только что", "download_button": "Скачать", "delete_button": "Удалить", "delete_confirm": "Удалить файл", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a26ba22..4c62034 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -388,6 +388,14 @@ "search_empty_desc": "请调整筛选条件或清空搜索关键字再试一次。", "file_size": "大小", "file_modified": "修改时间", + "health_status_label": "健康状态", + "health_status_healthy": "健康", + "health_status_warning": "警告", + "health_status_disabled": "已停用", + "health_status_unknown": "未知", + "last_refresh_label": "最近刷新", + "refresh_not_available": "暂无", + "refresh_just_now": "刚刚", "download_button": "下载", "delete_button": "删除", "delete_confirm": "确定要删除文件", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 8a01a0e..ce55d66 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -605,6 +605,69 @@ border-bottom: 1px solid var(--border-color); } +.cardHealthRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + flex-wrap: wrap; +} + +.healthStatusBadge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.healthStatusHealthy { + color: var(--success-badge-text, #065f46); + background-color: var(--success-badge-bg, #d1fae5); + border-color: var(--success-badge-border, #6ee7b7); +} + +.healthStatusWarning { + color: var(--warning-text); + background-color: var(--warning-bg); + border-color: var(--warning-border); +} + +.healthStatusDisabled { + color: var(--text-secondary); + background-color: var(--bg-tertiary); + border-color: var(--border-color); +} + +.healthStatusUnknown { + color: var(--text-secondary); + background-color: var(--bg-secondary); + border-style: dashed; +} + +.lastRefreshText { + font-size: 12px; + color: var(--text-secondary); +} + +.lastRefreshStale { + color: var(--warning-text); +} + +.healthStatusMessage { + font-size: 12px; + color: var(--warning-text); + background-color: var(--warning-bg); + border: 1px solid var(--warning-border); + border-radius: $radius-sm; + padding: 5px 8px; + word-break: break-word; +} + .cardStats { display: flex; flex-wrap: wrap; diff --git a/src/types/authFile.ts b/src/types/authFile.ts index 6431725..5061efa 100644 --- a/src/types/authFile.ts +++ b/src/types/authFile.ts @@ -25,6 +25,10 @@ export interface AuthFileItem { authIndex?: string | number | null; runtimeOnly?: boolean | string; disabled?: boolean; + unavailable?: boolean; + status?: string; + statusMessage?: string; + lastRefresh?: string | number; modified?: number; [key: string]: unknown; }