mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
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:
@@ -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 {
|
.versionCheck {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,56 +1,136 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { modelsApi } from '@/services/api/models';
|
import { modelsApi } from '@/services/api/models';
|
||||||
|
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||||
import { classifyModels, type ModelInfo } from '@/utils/models';
|
import { classifyModels, type ModelInfo } from '@/utils/models';
|
||||||
|
import styles from './SystemPage.module.scss';
|
||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const configStore = useConfigStore();
|
const { config, fetchConfig } = useConfigStore((state) => ({
|
||||||
|
config: state.config,
|
||||||
|
fetchConfig: state.fetchConfig
|
||||||
|
}));
|
||||||
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const openaiProviders = configStore.config?.openaiCompatibility || [];
|
const apiKeysCache = useRef<string[]>([]);
|
||||||
const primaryProvider = openaiProviders[0];
|
|
||||||
const primaryKey = primaryProvider?.apiKeyEntries?.[0]?.apiKey;
|
|
||||||
|
|
||||||
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 () => {
|
const normalizeApiKeyList = (input: any): string[] => {
|
||||||
if (!primaryProvider?.baseUrl) {
|
if (!Array.isArray(input)) return [];
|
||||||
showNotification('No OpenAI provider configured for model fetch', 'warning');
|
const seen = new Set<string>();
|
||||||
return;
|
const keys: string[] = [];
|
||||||
}
|
|
||||||
setLoadingModels(true);
|
input.forEach((item) => {
|
||||||
setError('');
|
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
||||||
try {
|
const trimmed = String(value || '').trim();
|
||||||
const list = await modelsApi.fetchModels(primaryProvider.baseUrl, primaryKey);
|
if (!trimmed || seen.has(trimmed)) return;
|
||||||
setModels(list);
|
seen.add(trimmed);
|
||||||
} catch (err: any) {
|
keys.push(trimmed);
|
||||||
setError(err?.message || t('notification.refresh_failed'));
|
});
|
||||||
} finally {
|
|
||||||
setLoadingModels(false);
|
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(() => {
|
useEffect(() => {
|
||||||
configStore.fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore
|
// ignore
|
||||||
});
|
});
|
||||||
}, []);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels();
|
||||||
|
}, [fetchModels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<Card
|
<Card
|
||||||
title={t('nav.system_info')}
|
title={t('system_info.title')}
|
||||||
extra={
|
extra={
|
||||||
<Button variant="secondary" size="sm" onClick={() => configStore.fetchConfig(undefined, true)}>
|
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -78,30 +158,39 @@ export function SystemPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Models"
|
title={t('system_info.models_title')}
|
||||||
extra={
|
extra={
|
||||||
<Button variant="secondary" size="sm" onClick={fetchModels} loading={loadingModels}>
|
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefreshKeys: true })} loading={loadingModels}>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</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>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{loadingModels ? (
|
{loadingModels ? (
|
||||||
<div className="hint">{t('common.loading')}</div>
|
<div className="hint">{t('common.loading')}</div>
|
||||||
) : models.length === 0 ? (
|
) : 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">
|
<div className="item-list">
|
||||||
{groupedModels.map((group) => (
|
{groupedModels.map((group) => (
|
||||||
<div key={group.id} className="item-row">
|
<div key={group.id} className="item-row">
|
||||||
<div className="item-meta">
|
<div className="item-meta">
|
||||||
<div className="item-title">
|
<div className="item-title">{group.label}</div>
|
||||||
{group.label} ({group.items.length})
|
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-subtitle">
|
<div className={styles.modelTags}>
|
||||||
{group.items.map((model) => model.name).slice(0, 5).join(', ')}
|
{group.items.map((model) => (
|
||||||
{group.items.length > 5 ? '…' : ''}
|
<span
|
||||||
</div>
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,25 +5,37 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { normalizeModelList } from '@/utils/models';
|
import { normalizeModelList } from '@/utils/models';
|
||||||
|
|
||||||
const buildModelsEndpoint = (baseUrl: string): string => {
|
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||||
if (!baseUrl) return '';
|
let normalized = String(baseUrl || '').trim();
|
||||||
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
|
if (!normalized) return '';
|
||||||
if (!trimmed) return '';
|
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
|
||||||
if (trimmed.endsWith('/v1')) {
|
normalized = normalized.replace(/\/+$/g, '');
|
||||||
return `${trimmed}/models`;
|
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 = {
|
export const modelsApi = {
|
||||||
async fetchModels(baseUrl: string, apiKey?: string) {
|
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
|
||||||
const endpoint = buildModelsEndpoint(baseUrl);
|
const endpoint = buildModelsEndpoint(baseUrl);
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new Error('Invalid base url');
|
throw new Error('Invalid base url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedHeaders = { ...headers };
|
||||||
|
if (apiKey) {
|
||||||
|
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await axios.get(endpoint, {
|
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;
|
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||||
return normalizeModelList(payload, { dedupe: true });
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import './variables.scss';
|
@use 'sass:color';
|
||||||
|
@use './variables.scss' as *;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: darken($error-color, 5%);
|
background-color: color.adjust($error-color, $lightness: -5%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
* 全局样式
|
* 全局样式
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import './variables.scss';
|
@use './variables.scss' as *;
|
||||||
@import './mixins.scss';
|
@use './mixins.scss' as *;
|
||||||
@import './reset.scss';
|
@use './reset.scss';
|
||||||
@import './themes.scss';
|
@use './themes.scss';
|
||||||
@import './components.scss';
|
@use './components.scss';
|
||||||
@import './layout.scss';
|
@use './layout.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './variables.scss';
|
@use './variables.scss' as *;
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SCSS 混入
|
* SCSS 混入
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@use './variables.scss' as *;
|
||||||
|
|
||||||
// 响应式断点
|
// 响应式断点
|
||||||
@mixin mobile {
|
@mixin mobile {
|
||||||
@media (max-width: #{$breakpoint-mobile}) {
|
@media (max-width: #{$breakpoint-mobile}) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* CSS Reset
|
* CSS Reset
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@use './variables.scss' as *;
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
|
|||||||
4
src/types/style.d.ts
vendored
Normal file
4
src/types/style.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.module.scss' {
|
||||||
|
const classes: Record<string, string>;
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
additionalData: `@import "@/styles/variables.scss";`
|
additionalData: `@use "@/styles/variables.scss" as *;`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user