refactor(system-page): clean up version check logic and enhance layout

This commit is contained in:
Supra4E8C
2026-03-17 01:09:35 +08:00
parent babb9b0d35
commit aa34487313
3 changed files with 173 additions and 139 deletions
-77
View File
@@ -31,7 +31,6 @@ import {
useNotificationStore,
useThemeStore,
} from '@/stores';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
@@ -70,12 +69,6 @@ const headerIcons = {
<path d="M21 3v5h-5" />
</svg>
),
update: (
<svg {...headerIconProps}>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
),
menu: (
<svg {...headerIconProps}>
<path d="M4 7h16" />
@@ -209,39 +202,12 @@ const THEME_CARDS: Array<{
},
];
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned
.split(/[^0-9]+/)
.filter(Boolean)
.map((segment) => Number.parseInt(segment, 10))
.filter(Number.isFinite);
return parts.length ? parts : null;
};
const compareVersions = (latest?: string | null, current?: string | null) => {
const latestParts = parseVersionSegments(latest);
const currentParts = parseVersionSegments(current);
if (!latestParts || !currentParts) return null;
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return 1;
if (l < c) return -1;
}
return 0;
};
export function MainLayout() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
@@ -256,7 +222,6 @@ export function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
@@ -542,39 +507,6 @@ export function MainLayout() {
showNotification(t('notification.data_refreshed'), 'success');
};
const handleVersionCheck = async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
showNotification(t('system_info.version_check_error'), 'error');
return;
}
if (comparison === null) {
showNotification(t('system_info.version_current_missing'), 'warning');
return;
}
if (comparison > 0) {
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
};
return (
<div className="app-shell">
<header className="main-header" ref={headerRef}>
@@ -632,15 +564,6 @@ export function MainLayout() {
>
{headerIcons.refresh}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update}
</Button>
<div
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
ref={languageMenuRef}
+20 -2
View File
@@ -78,7 +78,10 @@
color: inherit;
padding: $spacing-md $spacing-lg;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.18s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
@@ -97,6 +100,19 @@
color: var(--text-secondary);
}
.tileHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-sm;
}
.tileAction {
flex-shrink: 0;
white-space: nowrap;
margin: -4px -8px 0 0;
}
.tileValue {
font-size: 22px;
font-weight: 700;
@@ -206,7 +222,9 @@
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
.modelName {
+153 -60
View File
@@ -5,8 +5,14 @@ import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
import { configApi } from '@/services/api';
import {
useAuthStore,
useConfigStore,
useNotificationStore,
useModelsStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
@@ -36,6 +42,32 @@ const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: strin
minimax: iconMinimax,
};
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned
.split(/[^0-9]+/)
.filter(Boolean)
.map((segment) => Number.parseInt(segment, 10))
.filter(Number.isFinite);
return parts.length ? parts : null;
};
const compareVersions = (latest?: string | null, current?: string | null) => {
const latestParts = parseVersionSegments(latest);
const currentParts = parseVersionSegments(current);
if (!latestParts || !currentParts) return null;
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return 1;
if (l < c) return -1;
}
return 0;
};
export function SystemPage() {
const { t, i18n } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
@@ -51,11 +83,15 @@ export function SystemPage() {
const modelsError = useModelsStore((state) => state.error);
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [modelStatus, setModelStatus] = useState<{
type: 'success' | 'warning' | 'error' | 'muted';
message: string;
}>();
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const apiKeysCache = useRef<string[]>([]);
const versionTapCount = useRef(0);
@@ -136,7 +172,7 @@ export function SystemPage() {
if (auth.connectionStatus !== 'connected') {
setModelStatus({
type: 'warning',
message: t('notification.connection_required')
message: t('notification.connection_required'),
});
return;
}
@@ -158,11 +194,12 @@ export function SystemPage() {
const hasModels = list.length > 0;
setModelStatus({
type: hasModels ? 'success' : 'warning',
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
message: hasModels
? t('system_info.models_count', { count: list.length })
: t('system_info.models_empty'),
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const suffix = message ? `: ${message}` : '';
const text = `${t('system_info.models_error')}${suffix}`;
setModelStatus({ type: 'error', message: text });
@@ -244,6 +281,39 @@ export function SystemPage() {
}
};
const handleVersionCheck = useCallback(async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, auth.serverVersion);
if (!latest) {
showNotification(t('system_info.version_check_error'), 'error');
return;
}
if (comparison === null) {
showNotification(t('system_info.version_current_missing'), 'warning');
return;
}
if (comparison > 0) {
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
}, [auth.serverVersion, showNotification, t]);
useEffect(() => {
fetchConfig().catch(() => {
// ignore
@@ -290,7 +360,21 @@ export function SystemPage() {
</button>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<div className={styles.tileHeader}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.tileAction}
onClick={() => void handleVersionCheck()}
loading={checkingVersion}
title={t('system_info.version_check_button')}
aria-label={t('system_info.version_check_button')}
>
{t('system_info.version_check_button')}
</Button>
</div>
<div className={styles.tileValue}>{apiVersion}</div>
</div>
@@ -366,61 +450,70 @@ export function SystemPage() {
</div>
</Card>
<Card
title={t('system_info.models_title')}
extra={
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefresh: true })} loading={modelsLoading}>
{t('common.refresh')}
</Button>
}
>
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
{modelStatus && <div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>}
{modelsError && <div className="error-box">{modelsError}</div>}
{modelsLoading ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className={styles.groupTitle}>
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
<span className="item-title">{group.label}</span>
<Card
title={t('system_info.models_title')}
extra={
<Button
variant="secondary"
size="sm"
onClick={() => fetchModels({ forceRefresh: true })}
loading={modelsLoading}
>
{t('common.refresh')}
</Button>
}
>
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
{modelStatus && (
<div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>
)}
{modelsError && <div className="error-box">{modelsError}</div>}
{modelsLoading ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className={styles.groupTitle}>
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
<span className="item-title">{group.label}</span>
</div>
<div className="item-subtitle">
{t('system_info.models_count', { count: group.items.length })}
</div>
</div>
<div className={styles.modelTags}>
{group.items.map((model) => (
<span
key={`${model.name}-${model.alias ?? 'default'}`}
className={styles.modelTag}
title={model.description || ''}
>
<span className={styles.modelName}>{model.name}</span>
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
</span>
))}
</div>
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
</div>
<div className={styles.modelTags}>
{group.items.map((model) => (
<span
key={`${model.name}-${model.alias ?? 'default'}`}
className={styles.modelTag}
title={model.description || ''}
>
<span className={styles.modelName}>{model.name}</span>
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
</span>
))}
</div>
</div>
);
})}
</div>
)}
</Card>
);
})}
</div>
)}
</Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
</div>
<Modal