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;
}