feat: update SCSS imports to use new Sass module system, enhance SystemPage with model tags display and API key handling, and improve model fetching logic with better error handling and notifications

This commit is contained in:
Supra4E8C
2025-12-08 20:20:47 +08:00
parent 450964fb1a
commit 9d7db57c6a
10 changed files with 192 additions and 55 deletions

View File

@@ -77,6 +77,33 @@
}
}
.modelTags {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
}
.modelTag {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: 4px 10px;
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;
}
.modelName {
color: var(--text-primary);
font-weight: 600;
}
.modelAlias {
color: var(--text-secondary);
font-size: 12px;
}
.versionCheck {
display: flex;
flex-direction: column;

View File

@@ -1,56 +1,136 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { modelsApi } from '@/services/api/models';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels, type ModelInfo } from '@/utils/models';
import styles from './SystemPage.module.scss';
export function SystemPage() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore();
const auth = useAuthStore();
const configStore = useConfigStore();
const { config, fetchConfig } = useConfigStore((state) => ({
config: state.config,
fetchConfig: state.fetchConfig
}));
const [models, setModels] = useState<ModelInfo[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [error, setError] = useState('');
const openaiProviders = configStore.config?.openaiCompatibility || [];
const primaryProvider = openaiProviders[0];
const primaryKey = primaryProvider?.apiKeyEntries?.[0]?.apiKey;
const apiKeysCache = useRef<string[]>([]);
const groupedModels = useMemo(() => classifyModels(models, { otherLabel: 'Other' }), [models]);
const otherLabel = useMemo(
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
[i18n.language]
);
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
const fetchModels = async () => {
if (!primaryProvider?.baseUrl) {
showNotification('No OpenAI provider configured for model fetch', 'warning');
return;
}
setLoadingModels(true);
setError('');
try {
const list = await modelsApi.fetchModels(primaryProvider.baseUrl, primaryKey);
setModels(list);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoadingModels(false);
}
const normalizeApiKeyList = (input: any): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);
});
return keys;
};
const resolveApiKeysForModels = useCallback(async () => {
if (apiKeysCache.current.length) {
return apiKeysCache.current;
}
const configKeys = normalizeApiKeyList(config?.apiKeys);
if (configKeys.length) {
apiKeysCache.current = configKeys;
return configKeys;
}
try {
const list = await apiKeysApi.list();
const normalized = normalizeApiKeyList(list);
if (normalized.length) {
apiKeysCache.current = normalized;
}
return normalized;
} catch (err) {
console.warn('Auto loading API keys for models failed:', err);
return [];
}
}, [config?.apiKeys]);
const fetchModels = useCallback(
async ({ forceRefreshKeys = false }: { forceRefreshKeys?: boolean } = {}) => {
if (auth.connectionStatus !== 'connected') {
setModelStatus({
type: 'warning',
message: t('notification.connection_required')
});
setModels([]);
return;
}
if (!auth.apiBase) {
showNotification(t('notification.connection_required'), 'warning');
return;
}
if (forceRefreshKeys) {
apiKeysCache.current = [];
}
setLoadingModels(true);
setError('');
setModelStatus({ type: 'muted', message: t('system_info.models_loading') });
try {
const apiKeys = await resolveApiKeysForModels();
const primaryKey = apiKeys[0];
const list = await modelsApi.fetchModels(auth.apiBase, primaryKey);
setModels(list);
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')
});
} catch (err: any) {
const message = `${t('system_info.models_error')}: ${err?.message || ''}`;
setError(message);
setModels([]);
setModelStatus({ type: 'error', message });
} finally {
setLoadingModels(false);
}
},
[auth.apiBase, auth.connectionStatus, resolveApiKeysForModels, showNotification, t]
);
useEffect(() => {
configStore.fetchConfig().catch(() => {
fetchConfig().catch(() => {
// ignore
});
}, []);
}, [fetchConfig]);
useEffect(() => {
fetchModels();
}, [fetchModels]);
return (
<div className="stack">
<Card
title={t('nav.system_info')}
title={t('system_info.title')}
extra={
<Button variant="secondary" size="sm" onClick={() => configStore.fetchConfig(undefined, true)}>
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
}
@@ -78,30 +158,39 @@ export function SystemPage() {
</Card>
<Card
title="Models"
title={t('system_info.models_title')}
extra={
<Button variant="secondary" size="sm" onClick={fetchModels} loading={loadingModels}>
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefreshKeys: true })} loading={loadingModels}>
{t('common.refresh')}
</Button>
}
>
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
{modelStatus && <div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>}
{error && <div className="error-box">{error}</div>}
{loadingModels ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('usage_stats.no_data')}</div>
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className="item-title">
{group.label} ({group.items.length})
</div>
<div className="item-subtitle">
{group.items.map((model) => model.name).slice(0, 5).join(', ')}
{group.items.length > 5 ? '…' : ''}
</div>
<div className="item-title">{group.label}</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>
))}

View File

@@ -5,25 +5,37 @@
import axios from 'axios';
import { normalizeModelList } from '@/utils/models';
const buildModelsEndpoint = (baseUrl: string): string => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
if (trimmed.endsWith('/v1')) {
return `${trimmed}/models`;
const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim();
if (!normalized) return '';
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
normalized = normalized.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return `${trimmed}/v1/models`;
return normalized;
};
const buildModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return '';
return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`;
};
export const modelsApi = {
async fetchModels(baseUrl: string, apiKey?: string) {
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
const endpoint = buildModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const resolvedHeaders = { ...headers };
if (apiKey) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
const response = await axios.get(endpoint, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined
headers: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
});
const payload = response.data?.data ?? response.data?.models ?? response.data;
return normalizeModelList(payload, { dedupe: true });

View File

@@ -1,4 +1,5 @@
@import './variables.scss';
@use 'sass:color';
@use './variables.scss' as *;
.btn {
display: inline-flex;
@@ -52,7 +53,7 @@
color: #fff;
&:hover {
background-color: darken($error-color, 5%);
background-color: color.adjust($error-color, $lightness: -5%);
}
}

View File

@@ -2,12 +2,12 @@
* 全局样式
*/
@import './variables.scss';
@import './mixins.scss';
@import './reset.scss';
@import './themes.scss';
@import './components.scss';
@import './layout.scss';
@use './variables.scss' as *;
@use './mixins.scss' as *;
@use './reset.scss';
@use './themes.scss';
@use './components.scss';
@use './layout.scss';
body {
background-color: var(--bg-secondary);

View File

@@ -1,4 +1,4 @@
@import './variables.scss';
@use './variables.scss' as *;
.app-shell {
display: flex;

View File

@@ -2,6 +2,8 @@
* SCSS 混入
*/
@use './variables.scss' as *;
// 响应式断点
@mixin mobile {
@media (max-width: #{$breakpoint-mobile}) {

View File

@@ -2,6 +2,8 @@
* CSS Reset
*/
@use './variables.scss' as *;
*,
*::before,
*::after {

4
src/types/style.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.scss' {
const classes: Record<string, string>;
export default classes;
}