mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
3 Commits
v1.3.0
...
f8c4a434ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c4a434ed | ||
|
|
237cca5680 | ||
|
|
f0735dbc1e |
@@ -29,6 +29,18 @@
|
|||||||
|
|
||||||
&--stacked {
|
&--stacked {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
|
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
||||||
|
// Older stacked layers remain `display: none` for performance.
|
||||||
|
&.page-transition__layer--stacked-keep {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@
|
|||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--animating &__layer:not(.page-transition__layer--exit) {
|
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,20 +83,28 @@ export function PageTransition({
|
|||||||
};
|
};
|
||||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||||
const toIndex = resolveOrderIndex(location.pathname);
|
const toIndex = resolveOrderIndex(location.pathname);
|
||||||
const nextDirection: TransitionDirection =
|
const nextVariant: TransitionVariant = getTransitionVariant
|
||||||
|
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||||
|
: 'vertical';
|
||||||
|
|
||||||
|
let nextDirection: TransitionDirection =
|
||||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: toIndex > fromIndex
|
: toIndex > fromIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
|
|
||||||
|
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
||||||
|
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
||||||
|
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
||||||
|
nextDirection = 'backward';
|
||||||
|
}
|
||||||
|
|
||||||
transitionDirectionRef.current = nextDirection;
|
transitionDirectionRef.current = nextDirection;
|
||||||
transitionVariantRef.current = getTransitionVariant
|
transitionVariantRef.current = nextVariant;
|
||||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
|
||||||
: 'vertical';
|
|
||||||
|
|
||||||
const shouldSkipExitLayer = (() => {
|
const shouldSkipExitLayer = (() => {
|
||||||
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
|
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||||
const normalizeSegments = (pathname: string) =>
|
const normalizeSegments = (pathname: string) =>
|
||||||
pathname
|
pathname
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -178,6 +186,7 @@ export function PageTransition({
|
|||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
getTransitionVariant,
|
getTransitionVariant,
|
||||||
resolveScrollContainer,
|
resolveScrollContainer,
|
||||||
|
layers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Run GSAP animation when animating starts
|
// Run GSAP animation when animating starts
|
||||||
@@ -322,16 +331,30 @@ export function PageTransition({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||||
{layers.map((layer) => (
|
{(() => {
|
||||||
|
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
||||||
|
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
||||||
|
const keepStackedIndex = layers
|
||||||
|
.slice(0, resolvedCurrentIndex)
|
||||||
|
.map((layer, index) => ({ layer, index }))
|
||||||
|
.reverse()
|
||||||
|
.find(({ layer }) => layer.status === 'stacked')?.index;
|
||||||
|
|
||||||
|
return layers.map((layer, index) => {
|
||||||
|
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={layer.key}
|
key={layer.key}
|
||||||
className={[
|
className={[
|
||||||
'page-transition__layer',
|
'page-transition__layer',
|
||||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||||
|
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
aria-hidden={layer.status !== 'current'}
|
||||||
|
inert={layer.status !== 'current'}
|
||||||
ref={
|
ref={
|
||||||
layer.status === 'exiting'
|
layer.status === 'exiting'
|
||||||
? exitingLayerRef
|
? exitingLayerRef
|
||||||
@@ -342,7 +365,9 @@ export function PageTransition({
|
|||||||
>
|
>
|
||||||
{render(layer.location)}
|
{render(layer.location)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,9 +75,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 小屏幕隐藏悬浮导航
|
// 小屏幕改为底部横向浮层
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.navContainer {
|
.navContainer {
|
||||||
display: none;
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,25 +29,47 @@ const PROVIDERS: ProviderNavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const HEADER_OFFSET = 24;
|
const HEADER_OFFSET = 24;
|
||||||
|
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||||
|
|
||||||
export function ProviderNav() {
|
export function ProviderNav() {
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const getHeaderHeight = useCallback(() => {
|
||||||
|
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||||
|
if (header) return header.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
||||||
|
const value = Number.parseFloat(raw);
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getContentScroller = useCallback(() => {
|
||||||
|
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
||||||
|
return contentScrollerRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
const getScrollContainer = useCallback(() => {
|
|
||||||
if (scrollContainerRef.current) return scrollContainerRef.current;
|
|
||||||
const container = document.querySelector('.content') as HTMLElement | null;
|
const container = document.querySelector('.content') as HTMLElement | null;
|
||||||
scrollContainerRef.current = container;
|
contentScrollerRef.current = container;
|
||||||
return container;
|
return container;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||||
|
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
||||||
|
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||||
|
if (isMobile) return window;
|
||||||
|
return getContentScroller() ?? window;
|
||||||
|
}, [getContentScroller]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = getScrollContainer();
|
const container = getScrollContainer();
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const isElementScroller = container instanceof HTMLElement;
|
||||||
const activationLine = containerRect.top + HEADER_OFFSET + 1;
|
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
||||||
|
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
||||||
|
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
|
||||||
let currentActive: ProviderId | null = null;
|
let currentActive: ProviderId | null = null;
|
||||||
|
|
||||||
for (const provider of PROVIDERS) {
|
for (const provider of PROVIDERS) {
|
||||||
@@ -71,31 +93,44 @@ export function ProviderNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setActiveProvider(currentActive);
|
setActiveProvider(currentActive);
|
||||||
}, [getScrollContainer]);
|
}, [getHeaderHeight, getScrollContainer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = getScrollContainer();
|
const contentScroller = getContentScroller();
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleScroll);
|
||||||
handleScroll();
|
handleScroll();
|
||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => {
|
||||||
}, [handleScroll, getScrollContainer]);
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleScroll);
|
||||||
|
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [getContentScroller, handleScroll]);
|
||||||
|
|
||||||
const scrollToProvider = (providerId: ProviderId) => {
|
const scrollToProvider = (providerId: ProviderId) => {
|
||||||
const container = getScrollContainer();
|
const container = getScrollContainer();
|
||||||
const element = document.getElementById(`provider-${providerId}`);
|
const element = document.getElementById(`provider-${providerId}`);
|
||||||
if (!element || !container) return;
|
if (!element || !container) return;
|
||||||
|
|
||||||
|
setActiveProvider(providerId);
|
||||||
|
|
||||||
|
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||||
|
if (!(container instanceof HTMLElement)) {
|
||||||
|
const headerHeight = getHeaderHeight();
|
||||||
|
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
||||||
|
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const elementRect = element.getBoundingClientRect();
|
const elementRect = element.getBoundingClientRect();
|
||||||
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||||
|
|
||||||
setActiveProvider(providerId);
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||||
container.scrollTo({
|
|
||||||
top: scrollTop,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const navContent = (
|
const navContent = (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { providersApi } from '@/services/api';
|
import { providersApi } from '@/services/api';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
|
||||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||||
import type { ModelInfo } from '@/utils/models';
|
import type { ModelInfo } from '@/utils/models';
|
||||||
@@ -71,18 +71,69 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||||
|
|
||||||
const [providers, setProviders] = useState<OpenAIProviderConfig[]>([]);
|
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
|
||||||
const [form, setForm] = useState<OpenAIFormState>(() => buildEmptyForm());
|
() => config?.openaiCompatibility ?? []
|
||||||
const [testModel, setTestModel] = useState('');
|
);
|
||||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [loading, setLoading] = useState(
|
||||||
const [testMessage, setTestMessage] = useState('');
|
() => !isCacheValid('openai-compatibility')
|
||||||
const [loading, setLoading] = useState(true);
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const draftKey = useMemo(() => {
|
||||||
|
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
|
||||||
|
if (editIndex === null) return 'openai:new';
|
||||||
|
return `openai:${editIndex}`;
|
||||||
|
}, [editIndex, invalidIndexParam, params.index]);
|
||||||
|
|
||||||
|
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
|
||||||
|
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
|
||||||
|
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
|
||||||
|
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
|
||||||
|
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
|
||||||
|
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||||
|
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||||
|
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
||||||
|
|
||||||
|
const form = draft?.form ?? buildEmptyForm();
|
||||||
|
const testModel = draft?.testModel ?? '';
|
||||||
|
const testStatus = draft?.testStatus ?? 'idle';
|
||||||
|
const testMessage = draft?.testMessage ?? '';
|
||||||
|
|
||||||
|
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftForm(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftForm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestModel(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestModel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>> =
|
||||||
|
useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestStatus(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestMessage(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestMessage]
|
||||||
|
);
|
||||||
|
|
||||||
const initialData = useMemo(() => {
|
const initialData = useMemo(() => {
|
||||||
if (editIndex === null) return undefined;
|
if (editIndex === null) return undefined;
|
||||||
return providers[editIndex];
|
return providers[editIndex];
|
||||||
@@ -95,18 +146,26 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
[form.modelEntries]
|
[form.modelEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureDraft(draftKey);
|
||||||
|
}, [draftKey, ensureDraft]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
|
clearDraft(draftKey);
|
||||||
const state = location.state as LocationState;
|
const state = location.state as LocationState;
|
||||||
if (state?.fromAiProviders) {
|
if (state?.fromAiProviders) {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/ai-providers', { replace: true });
|
navigate('/ai-providers', { replace: true });
|
||||||
}, [location.state, navigate]);
|
}, [clearDraft, draftKey, location.state, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
const hasValidCache = isCacheValid('openai-compatibility');
|
||||||
|
if (!hasValidCache) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
fetchConfig('openai-compatibility')
|
fetchConfig('openai-compatibility')
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
@@ -126,14 +185,15 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchConfig, showNotification, t]);
|
}, [fetchConfig, isCacheValid, showNotification, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (draft?.initialized) return;
|
||||||
|
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const modelEntries = modelsToEntries(initialData.models);
|
const modelEntries = modelsToEntries(initialData.models);
|
||||||
setForm({
|
const seededForm: OpenAIFormState = {
|
||||||
name: initialData.name,
|
name: initialData.name,
|
||||||
prefix: initialData.prefix ?? '',
|
prefix: initialData.prefix ?? '',
|
||||||
baseUrl: initialData.baseUrl,
|
baseUrl: initialData.baseUrl,
|
||||||
@@ -143,22 +203,28 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||||
? initialData.apiKeyEntries
|
? initialData.apiKeyEntries
|
||||||
: [buildApiKeyEntry()],
|
: [buildApiKeyEntry()],
|
||||||
});
|
};
|
||||||
|
|
||||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||||
const initialTestModel =
|
const initialTestModel =
|
||||||
initialData.testModel && available.includes(initialData.testModel)
|
initialData.testModel && available.includes(initialData.testModel)
|
||||||
? initialData.testModel
|
? initialData.testModel
|
||||||
: available[0] || '';
|
: available[0] || '';
|
||||||
setTestModel(initialTestModel);
|
initDraft(draftKey, {
|
||||||
|
form: seededForm,
|
||||||
|
testModel: initialTestModel,
|
||||||
|
testStatus: 'idle',
|
||||||
|
testMessage: '',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm(buildEmptyForm());
|
initDraft(draftKey, {
|
||||||
setTestModel('');
|
form: buildEmptyForm(),
|
||||||
|
testModel: '',
|
||||||
|
testStatus: 'idle',
|
||||||
|
testMessage: '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}, [initialData, loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -183,32 +249,34 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
(selectedModels: ModelInfo[]) => {
|
(selectedModels: ModelInfo[]) => {
|
||||||
if (!selectedModels.length) return;
|
if (!selectedModels.length) return;
|
||||||
|
|
||||||
const mergedMap = new Map<string, ModelEntry>();
|
|
||||||
form.modelEntries.forEach((entry) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) return;
|
|
||||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
selectedModels.forEach((model) => {
|
setForm((prev) => {
|
||||||
const name = model.name.trim();
|
const mergedMap = new Map<string, ModelEntry>();
|
||||||
if (!name || mergedMap.has(name)) return;
|
prev.modelEntries.forEach((entry) => {
|
||||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
const name = entry.name.trim();
|
||||||
addedCount += 1;
|
if (!name) return;
|
||||||
});
|
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||||
|
});
|
||||||
|
|
||||||
const mergedEntries = Array.from(mergedMap.values());
|
selectedModels.forEach((model) => {
|
||||||
setForm((prev) => ({
|
const name = model.name.trim();
|
||||||
...prev,
|
if (!name || mergedMap.has(name)) return;
|
||||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||||
}));
|
addedCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedEntries = Array.from(mergedMap.values());
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (addedCount > 0) {
|
if (addedCount > 0) {
|
||||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[form.modelEntries, showNotification, t]
|
[setForm, showNotification, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -225,7 +293,8 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
headers: entry.headers,
|
headers: entry.headers,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (form.testModel) payload.testModel = form.testModel.trim();
|
const resolvedTestModel = testModel.trim();
|
||||||
|
if (resolvedTestModel) payload.testModel = resolvedTestModel;
|
||||||
const models = entriesToModels(form.modelEntries);
|
const models = entriesToModels(form.modelEntries);
|
||||||
if (models.length) payload.models = models;
|
if (models.length) payload.models = models;
|
||||||
|
|
||||||
@@ -256,11 +325,14 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
form,
|
form,
|
||||||
handleBack,
|
handleBack,
|
||||||
providers,
|
providers,
|
||||||
|
testModel,
|
||||||
showNotification,
|
showNotification,
|
||||||
t,
|
t,
|
||||||
updateConfigValue,
|
updateConfigValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const resolvedLoading = !draft?.initialized;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Outlet
|
<Outlet
|
||||||
context={{
|
context={{
|
||||||
@@ -269,7 +341,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
invalidIndexParam,
|
invalidIndexParam,
|
||||||
invalidIndex,
|
invalidIndex,
|
||||||
disableControls,
|
disableControls,
|
||||||
loading,
|
loading: resolvedLoading,
|
||||||
saving,
|
saving,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function LoginPage() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
navigate(redirect, { replace: true });
|
navigate(redirect, { replace: true });
|
||||||
}, 1300);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
setApiBase(storedBase || detectedBase);
|
setApiBase(storedBase || detectedBase);
|
||||||
setManagementKey(storedKey || '');
|
setManagementKey(storedKey || '');
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore';
|
|||||||
export { useConfigStore } from './useConfigStore';
|
export { useConfigStore } from './useConfigStore';
|
||||||
export { useModelsStore } from './useModelsStore';
|
export { useModelsStore } from './useModelsStore';
|
||||||
export { useQuotaStore } from './useQuotaStore';
|
export { useQuotaStore } from './useQuotaStore';
|
||||||
|
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';
|
||||||
|
|||||||
148
src/stores/useOpenAIEditDraftStore.ts
Normal file
148
src/stores/useOpenAIEditDraftStore.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI provider editor draft state.
|
||||||
|
*
|
||||||
|
* Why this exists:
|
||||||
|
* - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`.
|
||||||
|
* - Entering `/ai-providers/openai/.../models` creates a new route layer, so component-local state
|
||||||
|
* inside the OpenAI edit layout is not shared between the edit screen and the model picker screen.
|
||||||
|
* - This store makes the OpenAI edit draft shared across route layers keyed by provider index/new.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SetStateAction } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { OpenAIFormState } from '@/components/providers/types';
|
||||||
|
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||||
|
|
||||||
|
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export type OpenAIEditDraft = {
|
||||||
|
initialized: boolean;
|
||||||
|
form: OpenAIFormState;
|
||||||
|
testModel: string;
|
||||||
|
testStatus: OpenAITestStatus;
|
||||||
|
testMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OpenAIEditDraftState {
|
||||||
|
drafts: Record<string, OpenAIEditDraft>;
|
||||||
|
ensureDraft: (key: string) => void;
|
||||||
|
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
|
||||||
|
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
|
||||||
|
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||||
|
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
||||||
|
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
|
||||||
|
clearDraft: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAction = <T,>(action: SetStateAction<T>, prev: T): T =>
|
||||||
|
typeof action === 'function' ? (action as (previous: T) => T)(prev) : action;
|
||||||
|
|
||||||
|
const buildEmptyForm = (): OpenAIFormState => ({
|
||||||
|
name: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
headers: [],
|
||||||
|
apiKeyEntries: [buildApiKeyEntry()],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
testModel: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildEmptyDraft = (): OpenAIEditDraft => ({
|
||||||
|
initialized: false,
|
||||||
|
form: buildEmptyForm(),
|
||||||
|
testModel: '',
|
||||||
|
testStatus: 'idle',
|
||||||
|
testMessage: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
|
||||||
|
drafts: {},
|
||||||
|
|
||||||
|
ensureDraft: (key) => {
|
||||||
|
if (!key) return;
|
||||||
|
const existing = get().drafts[key];
|
||||||
|
if (existing) return;
|
||||||
|
set((state) => ({
|
||||||
|
drafts: { ...state.drafts, [key]: buildEmptyDraft() },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
initDraft: (key, draft) => {
|
||||||
|
if (!key) return;
|
||||||
|
const existing = get().drafts[key];
|
||||||
|
if (existing?.initialized) return;
|
||||||
|
set((state) => ({
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[key]: { ...draft, initialized: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftForm: (key, action) => {
|
||||||
|
if (!key) return;
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||||
|
const nextForm = resolveAction(action, existing.form);
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[key]: { ...existing, initialized: true, form: nextForm },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftTestModel: (key, action) => {
|
||||||
|
if (!key) return;
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||||
|
const nextValue = resolveAction(action, existing.testModel);
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[key]: { ...existing, initialized: true, testModel: nextValue },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftTestStatus: (key, action) => {
|
||||||
|
if (!key) return;
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||||
|
const nextValue = resolveAction(action, existing.testStatus);
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[key]: { ...existing, initialized: true, testStatus: nextValue },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftTestMessage: (key, action) => {
|
||||||
|
if (!key) return;
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||||
|
const nextValue = resolveAction(action, existing.testMessage);
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[key]: { ...existing, initialized: true, testMessage: nextValue },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearDraft: (key) => {
|
||||||
|
if (!key) return;
|
||||||
|
set((state) => {
|
||||||
|
if (!state.drafts[key]) return state;
|
||||||
|
const next = { ...state.drafts };
|
||||||
|
delete next[key];
|
||||||
|
return { drafts: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
Reference in New Issue
Block a user