mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
20 Commits
@@ -229,23 +229,161 @@
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNav {
|
||||
display: none;
|
||||
|
||||
@include mobile {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height, 64px) + 12px);
|
||||
z-index: 4;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavScroller {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 2px 10px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavButton {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.28);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileSectionNavButtonActive {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
|
||||
}
|
||||
|
||||
.mobileSectionNavIndex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mobileSectionNavLabel {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobileSectionNavBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
position: relative;
|
||||
align-self: start;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
position: static;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarPlaceholder {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.sidebarRail {
|
||||
max-height: calc(100vh - var(--header-height, 64px) - 36px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingSidebarContainer {
|
||||
position: fixed;
|
||||
left: var(--visual-config-floating-left, 16px);
|
||||
top: var(--visual-config-floating-top, 120px);
|
||||
width: var(--visual-config-floating-width, 280px);
|
||||
max-height: var(--visual-config-floating-max-height, calc(100vh - 136px));
|
||||
z-index: 45;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingSidebarRail {
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navList {
|
||||
@@ -393,6 +531,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionStack {
|
||||
@@ -675,9 +817,19 @@
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.overview {
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.overviewFocusLink {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
border-radius: 26px;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
@@ -685,7 +837,41 @@
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.blockHeaderRow,
|
||||
.ruleCardHeader {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.blockHeaderRow :global(.btn),
|
||||
.ruleCardHeader :global(.btn),
|
||||
.actionRow :global(.btn),
|
||||
.stringListRow :global(.btn) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.overview,
|
||||
.sections {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.subsection,
|
||||
.ruleCard,
|
||||
.toggleRow {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -8,7 +9,9 @@ import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
type IconProps,
|
||||
} from '@/components/ui/icons';
|
||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadParamValidationErrorCode,
|
||||
@@ -174,6 +178,11 @@ export function VisualConfigEditor({
|
||||
onChange,
|
||||
}: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
|
||||
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
|
||||
const routingStrategyLabelId = useId();
|
||||
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
|
||||
const keepaliveInputId = useId();
|
||||
@@ -183,7 +192,14 @@ export function VisualConfigEditor({
|
||||
const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`;
|
||||
const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`;
|
||||
const [activeSectionId, setActiveSectionId] = useState<VisualSectionId>('server');
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const sidebarAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const floatingSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
const sectionRefs = useRef<Partial<Record<VisualSectionId, HTMLElement | null>>>({});
|
||||
const mobileNavScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileNavButtonRefs = useRef<Partial<Record<VisualSectionId, HTMLButtonElement | null>>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const isKeepaliveDisabled =
|
||||
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
|
||||
@@ -343,11 +359,156 @@ export function VisualConfigEditor({
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const scroller = mobileNavScrollerRef.current;
|
||||
const button = mobileNavButtonRefs.current[activeSectionId];
|
||||
if (!scroller || !button) return;
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centeredLeft =
|
||||
scroller.scrollLeft +
|
||||
(buttonRect.left - scrollerRect.left) -
|
||||
(scroller.clientWidth - buttonRect.width) / 2;
|
||||
const maxScrollLeft = Math.max(scroller.scrollWidth - scroller.clientWidth, 0);
|
||||
const targetLeft = Math.min(Math.max(centeredLeft, 0), maxScrollLeft);
|
||||
|
||||
scroller.scrollTo({
|
||||
left: targetLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [activeSectionId, isMobile]);
|
||||
|
||||
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
|
||||
setActiveSectionId(sectionId);
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const floatingElement = floatingSidebarRef.current;
|
||||
const anchorElement = sidebarAnchorRef.current;
|
||||
const workspaceElement = workspaceRef.current;
|
||||
if (!floatingElement) return undefined;
|
||||
|
||||
const clearFloatingStyles = () => {
|
||||
floatingElement.style.removeProperty('--visual-config-floating-left');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-top');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-width');
|
||||
floatingElement.style.removeProperty('--visual-config-floating-max-height');
|
||||
floatingElement.style.removeProperty('opacity');
|
||||
floatingElement.style.removeProperty('pointer-events');
|
||||
};
|
||||
|
||||
if (!shouldRenderFloatingSidebar || !anchorElement || !workspaceElement) {
|
||||
clearFloatingStyles();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getHeaderHeight = () => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
if (header) return header.getBoundingClientRect().height;
|
||||
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 64;
|
||||
};
|
||||
|
||||
const getContentScroller = () => document.querySelector('.content') as HTMLElement | null;
|
||||
let frameId = 0;
|
||||
|
||||
const updateFloatingPosition = () => {
|
||||
frameId = 0;
|
||||
|
||||
const anchorRect = anchorElement.getBoundingClientRect();
|
||||
const workspaceRect = workspaceElement.getBoundingClientRect();
|
||||
const floatingHeight = floatingElement.getBoundingClientRect().height;
|
||||
const stickyTop = getHeaderHeight() + 20;
|
||||
const viewportPadding = 16;
|
||||
const maxTop = workspaceRect.bottom - floatingHeight;
|
||||
const unclampedTop = Math.min(Math.max(anchorRect.top, stickyTop), maxTop);
|
||||
const top = Math.max(unclampedTop, viewportPadding);
|
||||
const left = Math.max(anchorRect.left, viewportPadding);
|
||||
const width = Math.max(
|
||||
Math.min(anchorRect.width, window.innerWidth - left - viewportPadding),
|
||||
220
|
||||
);
|
||||
const maxHeight = Math.max(window.innerHeight - top - viewportPadding, 160);
|
||||
const isVisible = workspaceRect.bottom > stickyTop + 24 && anchorRect.top < window.innerHeight;
|
||||
|
||||
floatingElement.style.setProperty('--visual-config-floating-left', `${left}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-top', `${top}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-width', `${width}px`);
|
||||
floatingElement.style.setProperty('--visual-config-floating-max-height', `${maxHeight}px`);
|
||||
floatingElement.style.opacity = isVisible ? '1' : '0';
|
||||
floatingElement.style.pointerEvents = isVisible ? 'auto' : 'none';
|
||||
};
|
||||
|
||||
const requestPositionUpdate = () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(updateFloatingPosition);
|
||||
};
|
||||
|
||||
requestPositionUpdate();
|
||||
|
||||
const contentScroller = getContentScroller();
|
||||
window.addEventListener('resize', requestPositionUpdate);
|
||||
window.addEventListener('scroll', requestPositionUpdate, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', requestPositionUpdate, { passive: true });
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(requestPositionUpdate);
|
||||
resizeObserver?.observe(anchorElement);
|
||||
resizeObserver?.observe(workspaceElement);
|
||||
resizeObserver?.observe(floatingElement);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', requestPositionUpdate);
|
||||
window.removeEventListener('scroll', requestPositionUpdate);
|
||||
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
|
||||
clearFloatingStyles();
|
||||
};
|
||||
}, [shouldRenderFloatingSidebar]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navList}>
|
||||
{sections.map((section, index) => {
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
className={`${styles.navButton} ${
|
||||
activeSectionId === section.id ? styles.navButtonActive : ''
|
||||
}`}
|
||||
onClick={() => handleSectionJump(section.id)}
|
||||
>
|
||||
<span className={styles.navIndex}>{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className={styles.navMain}>
|
||||
<span className={styles.navHeadingRow}>
|
||||
<span className={styles.navLabelWrap}>
|
||||
<span className={styles.navIcon}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className={styles.navLabel}>{section.title}</span>
|
||||
</span>
|
||||
{section.errorCount > 0 ? (
|
||||
<span className={styles.navBadge} aria-hidden="true">
|
||||
{section.errorCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.navDescription}>{section.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.visualEditor}>
|
||||
<div className={styles.overview}>
|
||||
@@ -395,44 +556,47 @@ export function VisualConfigEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sidebarRail}>
|
||||
<div className={styles.navList}>
|
||||
{sections.map((section, index) => {
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
className={`${styles.navButton} ${
|
||||
activeSectionId === section.id ? styles.navButtonActive : ''
|
||||
}`}
|
||||
onClick={() => handleSectionJump(section.id)}
|
||||
>
|
||||
<span className={styles.navIndex}>{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className={styles.navMain}>
|
||||
<span className={styles.navHeadingRow}>
|
||||
<span className={styles.navLabelWrap}>
|
||||
<span className={styles.navIcon}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className={styles.navLabel}>{section.title}</span>
|
||||
</span>
|
||||
{section.errorCount > 0 ? (
|
||||
<span className={styles.navBadge} aria-hidden="true">
|
||||
{section.errorCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.navDescription}>{section.description}</span>
|
||||
<div ref={workspaceRef} className={styles.workspace}>
|
||||
{isMobile ? (
|
||||
<div className={styles.mobileSectionNav}>
|
||||
<div
|
||||
ref={mobileNavScrollerRef}
|
||||
className={styles.mobileSectionNavScroller}
|
||||
aria-label={t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<button
|
||||
key={section.id}
|
||||
ref={(node) => {
|
||||
mobileNavButtonRefs.current[section.id] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={`${styles.mobileSectionNavButton} ${
|
||||
activeSectionId === section.id ? styles.mobileSectionNavButtonActive : ''
|
||||
}`}
|
||||
onClick={() => handleSectionJump(section.id)}
|
||||
>
|
||||
<span className={styles.mobileSectionNavIndex}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.mobileSectionNavLabel}>{section.title}</span>
|
||||
{section.errorCount > 0 ? (
|
||||
<span className={styles.mobileSectionNavBadge} aria-hidden="true">
|
||||
{section.errorCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<aside ref={sidebarAnchorRef} className={styles.sidebar}>
|
||||
{isFloatingSidebar ? (
|
||||
<div className={styles.sidebarPlaceholder} aria-hidden="true" />
|
||||
) : (
|
||||
<div className={styles.sidebarRail}>{navContent}</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className={styles.sections}>
|
||||
@@ -940,6 +1104,15 @@ export function VisualConfigEditor({
|
||||
</ConfigSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldRenderFloatingSidebar && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div ref={floatingSidebarRef} className={styles.floatingSidebarContainer}>
|
||||
<div className={styles.floatingSidebarRail}>{navContent}</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export function Modal({
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width }}
|
||||
style={{ width, maxWidth: '100%' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
|
||||
@@ -67,11 +67,7 @@
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
@@ -83,6 +79,7 @@
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.option {
|
||||
|
||||
+192
-56
@@ -1,4 +1,14 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconChevronDown } from './icons';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
@@ -21,6 +31,51 @@ interface SelectProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const DROPDOWN_OFFSET = 6;
|
||||
const DROPDOWN_MAX_HEIGHT = 240;
|
||||
const DROPDOWN_Z_INDEX = 2010;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
|
||||
const left = clamp(
|
||||
rect.left,
|
||||
VIEWPORT_MARGIN,
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
|
||||
);
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const direction =
|
||||
spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
|
||||
const maxHeight = Math.max(
|
||||
0,
|
||||
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
|
||||
);
|
||||
|
||||
return direction === 'down'
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: rect.bottom + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX
|
||||
};
|
||||
};
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
@@ -40,17 +95,78 @@ export function Select({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disabled) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [disabled, open]);
|
||||
|
||||
const isOpen = open && !disabled;
|
||||
const updateDropdownStyle = useCallback(() => {
|
||||
if (!wrapRef.current) return;
|
||||
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
|
||||
}, []);
|
||||
|
||||
const scheduleDropdownStyleUpdate = useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateDropdownStyle();
|
||||
});
|
||||
}, [updateDropdownStyle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (rafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateDropdownStyle();
|
||||
|
||||
const handleViewportChange = () => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
};
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && wrapRef.current
|
||||
? new ResizeObserver(() => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (resizeObserver && wrapRef.current) {
|
||||
resizeObserver.observe(wrapRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
|
||||
|
||||
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
|
||||
const resolvedHighlightedIndex =
|
||||
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
|
||||
@@ -136,60 +252,80 @@ export function Select({
|
||||
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || resolvedHighlightedIndex < 0) return;
|
||||
const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`);
|
||||
highlightedOption?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isOpen, resolvedHighlightedIndex, selectId]);
|
||||
|
||||
const dropdown =
|
||||
isOpen && dropdownStyle
|
||||
? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={styles.dropdown}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
<>
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className={styles.triggerIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className={styles.triggerIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SelectionCheckbox({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
|
||||
{label ? <span className={textClassName}>{label}</span> : null}
|
||||
{label ? <div className={textClassName}>{label}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,43 +45,57 @@ export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||
|
||||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||
// 标签类型颜色配置 — 基于各提供商 Logo 品牌色调配,确保彼此不重复
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
// Qwen logo: 紫罗兰渐变 #6336E7 → #6F69F7
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
light: { bg: '#ede5fd', text: '#5530c7' },
|
||||
dark: { bg: '#36208a', text: '#b5a3f0' },
|
||||
},
|
||||
// Kimi logo: 亮蓝 #027AFF(K字 + 蓝色圆点)
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||
light: { bg: '#dce8ff', text: '#0560cf' },
|
||||
dark: { bg: '#003880', text: '#70b5ff' },
|
||||
},
|
||||
// Gemini logo: 多色蓝 #3186FF(偏柔和的蓝)
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
// Gemini-CLI: 同 Gemini 图标,用更深的海军蓝区分
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
light: { bg: '#e0e8ff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
// AI Studio: 使用 Gemini 图标,中性灰标签
|
||||
aistudio: {
|
||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
// Claude logo: 陶土橙 #D97757
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||
light: { bg: '#fbece4', text: '#c05621' },
|
||||
dark: { bg: '#5e2c14', text: '#e8a882' },
|
||||
},
|
||||
// Codex logo: 靛蓝渐变 #B1A7FF → #3941FF
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
light: { bg: '#eae7ff', text: '#3538d4' },
|
||||
dark: { bg: '#262395', text: '#b5b0ff' },
|
||||
},
|
||||
// Antigravity logo: 多色(主色 #3789F9 蓝 + #53A89A 青绿),用青色区分
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
// iFlow logo: 品红紫渐变 #5C5CFF → #AE5CFF,偏品红以区别于 Qwen 的紫罗兰
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||
light: { bg: '#f5e3fc', text: '#9025c8' },
|
||||
dark: { bg: '#521490', text: '#d49cf5' },
|
||||
},
|
||||
// Vertex logo: Google 蓝 #4285F4
|
||||
vertex: {
|
||||
light: { bg: '#e4edfd', text: '#2b5fbc' },
|
||||
dark: { bg: '#1a3d80', text: '#89b3f7' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AuthFilesUiState = {
|
||||
};
|
||||
|
||||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||
const AUTH_FILES_COMPACT_MODE_KEY = 'authFilesPage.compactMode';
|
||||
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
|
||||
|
||||
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
|
||||
@@ -40,3 +41,23 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const readPersistedAuthFilesCompactMode = (): boolean | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_FILES_COMPACT_MODE_KEY);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) === true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writePersistedAuthFilesCompactMode = (compactMode: boolean) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(AUTH_FILES_COMPACT_MODE_KEY, JSON.stringify(compactMode));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||
"gemini_models_fetch_error": "Failed to fetch Gemini models",
|
||||
"gemini_models_fetch_apply": "Add selected models",
|
||||
"model_discovery_select_visible": "Select current list",
|
||||
"model_discovery_clear_selection": "Clear selection",
|
||||
"model_discovery_selected_count": "{{count}} selected",
|
||||
"gemini_models_search_label": "Search models",
|
||||
"gemini_models_search_placeholder": "Filter by name, alias, or description",
|
||||
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
|
||||
@@ -1130,16 +1133,24 @@
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
"status_loading": "Loading configuration...",
|
||||
"status_loading_short": "Loading",
|
||||
"status_loaded": "Configuration loaded",
|
||||
"status_loaded_short": "Loaded",
|
||||
"status_dirty": "Unsaved changes",
|
||||
"status_dirty_short": "Unsaved",
|
||||
"status_disconnected": "Connect to the server to load the configuration",
|
||||
"status_disconnected_short": "Disconnected",
|
||||
"status_load_failed": "Load failed",
|
||||
"status_load_failed_short": "Failed",
|
||||
"status_saving": "Saving configuration...",
|
||||
"status_saving_short": "Saving",
|
||||
"status_saved": "Configuration saved",
|
||||
"status_save_failed": "Save failed",
|
||||
"save_success": "Configuration saved successfully",
|
||||
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
|
||||
"visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed",
|
||||
"visual_mode_unavailable_short": "YAML issue",
|
||||
"validation_blocked_short": "Fix errors",
|
||||
"visual_mode_unavailable_detail": "Visual editor is unavailable because the configuration contains invalid YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Cannot save from visual mode until the YAML syntax is fixed",
|
||||
"visual_mode_latest_yaml_invalid": "The latest server configuration contains invalid YAML. Review it in source mode before saving visual changes: {{message}}",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
|
||||
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
|
||||
"gemini_models_fetch_apply": "Добавить выбранные модели",
|
||||
"model_discovery_select_visible": "Выбрать текущий список",
|
||||
"model_discovery_clear_selection": "Очистить выбор",
|
||||
"model_discovery_selected_count": "Выбрано: {{count}}",
|
||||
"gemini_models_search_label": "Поиск моделей",
|
||||
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
|
||||
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
|
||||
@@ -1133,16 +1136,24 @@
|
||||
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
|
||||
"status_idle": "Ожидание действия",
|
||||
"status_loading": "Загрузка конфигурации...",
|
||||
"status_loading_short": "Загрузка",
|
||||
"status_loaded": "Конфигурация загружена",
|
||||
"status_loaded_short": "Загружено",
|
||||
"status_dirty": "Есть несохранённые изменения",
|
||||
"status_dirty_short": "Несохранено",
|
||||
"status_disconnected": "Подключитесь к серверу, чтобы загрузить конфигурацию",
|
||||
"status_disconnected_short": "Нет связи",
|
||||
"status_load_failed": "Не удалось загрузить",
|
||||
"status_load_failed_short": "Ошибка",
|
||||
"status_saving": "Сохранение конфигурации...",
|
||||
"status_saving_short": "Сохранение",
|
||||
"status_saved": "Конфигурация сохранена",
|
||||
"status_save_failed": "Не удалось сохранить",
|
||||
"save_success": "Конфигурация успешно сохранена",
|
||||
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
|
||||
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_unavailable_short": "Ошибка YAML",
|
||||
"validation_blocked_short": "Есть ошибки",
|
||||
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
|
||||
"gemini_models_fetch_error": "获取 Gemini 模型失败",
|
||||
"gemini_models_fetch_apply": "添加所选模型",
|
||||
"model_discovery_select_visible": "全选当前列表",
|
||||
"model_discovery_clear_selection": "清空已选",
|
||||
"model_discovery_selected_count": "已选 {{count}} 项",
|
||||
"gemini_models_search_label": "搜索模型",
|
||||
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
|
||||
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||
@@ -1130,16 +1133,24 @@
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
"status_loading": "加载配置中...",
|
||||
"status_loading_short": "加载中",
|
||||
"status_loaded": "配置已加载",
|
||||
"status_loaded_short": "已加载",
|
||||
"status_dirty": "有未保存的更改",
|
||||
"status_dirty_short": "未保存",
|
||||
"status_disconnected": "请先连接服务器以加载配置",
|
||||
"status_disconnected_short": "未连接",
|
||||
"status_load_failed": "加载失败",
|
||||
"status_load_failed_short": "失败",
|
||||
"status_saving": "正在保存配置...",
|
||||
"status_saving_short": "保存中",
|
||||
"status_saved": "配置保存完成",
|
||||
"status_save_failed": "保存失败",
|
||||
"save_success": "配置已保存",
|
||||
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
|
||||
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
|
||||
"visual_mode_unavailable_short": "YAML错误",
|
||||
"validation_blocked_short": "待修复",
|
||||
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
|
||||
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
|
||||
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersClaudeModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchClaudeModelDiscovery = useCallback(async () => {
|
||||
setFetching(true);
|
||||
@@ -117,6 +126,22 @@ export function AiProvidersClaudeModelsPage() {
|
||||
void fetchClaudeModelDiscovery();
|
||||
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -145,6 +170,18 @@ export function AiProvidersClaudeModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -153,7 +190,7 @@ export function AiProvidersClaudeModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -219,9 +256,42 @@ export function AiProvidersClaudeModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.claude_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -231,29 +301,30 @@ export function AiProvidersClaudeModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
@@ -66,7 +67,9 @@ const buildCodexSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
websockets: Boolean(form.websockets),
|
||||
@@ -95,7 +98,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildCodexSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -119,7 +124,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -216,6 +223,16 @@ export function AiProvidersCodexEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -247,7 +264,10 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.codex_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -320,6 +340,22 @@ export function AiProvidersCodexEditPage() {
|
||||
void fetchCodexModelDiscovery();
|
||||
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -332,8 +368,22 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +424,9 @@ export function AiProvidersCodexEditPage() {
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||
editIndex !== null
|
||||
? t('notification.codex_config_updated')
|
||||
: t('notification.codex_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -407,7 +459,8 @@ export function AiProvidersCodexEditPage() {
|
||||
!invalidIndexParam &&
|
||||
!invalidIndex &&
|
||||
Boolean((form.baseUrl ?? '').trim());
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -514,7 +567,9 @@ export function AiProvidersCodexEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.codex_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -595,7 +650,9 @@ export function AiProvidersCodexEditPage() {
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.codex_models_fetch_url_label')}
|
||||
@@ -624,41 +681,86 @@ export function AiProvidersCodexEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
@@ -42,7 +43,9 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
};
|
||||
|
||||
const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
@@ -61,7 +64,9 @@ const buildGeminiSignature = (form: GeminiFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
@@ -89,7 +94,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildGeminiSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -113,7 +120,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -196,6 +205,16 @@ export function AiProvidersGeminiEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -225,7 +244,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.gemini_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -306,6 +328,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
void fetchGeminiModelDiscovery();
|
||||
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -318,8 +356,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +426,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue('gemini-api-key', nextList);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||
editIndex !== null
|
||||
? t('notification.gemini_key_updated')
|
||||
: t('notification.gemini_key_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -400,8 +454,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canOpenModelDiscovery =
|
||||
!disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -501,7 +557,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.gemini_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -572,14 +630,20 @@ export function AiProvidersGeminiEditPage() {
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyDiscoveredModels}
|
||||
disabled={!canApplyModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.gemini_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.gemini_models_fetch_url_label')}
|
||||
@@ -608,41 +672,86 @@ export function AiProvidersGeminiEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -213,7 +213,7 @@ export function AiProvidersOpenAIEditPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
if (!hasHeader(headers, 'authorization')) {
|
||||
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
@@ -59,7 +68,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
try {
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const hasAuthHeader = hasHeader(headerObject, 'authorization');
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
@@ -98,6 +107,22 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -126,6 +151,18 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -134,7 +171,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -200,9 +237,42 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.openai_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -212,29 +282,30 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -327,15 +327,18 @@
|
||||
}
|
||||
|
||||
.modelDiscoveryRow {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin-top: 2px;
|
||||
@@ -348,6 +351,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelDiscoveryRowSelected {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-tertiary);
|
||||
@@ -376,6 +384,27 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionSummary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.openaiTestButtonSuccess {
|
||||
background-color: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
@@ -417,7 +446,9 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.statusBlockWrapper:hover &,
|
||||
.statusBlockWrapper.statusBlockActive & {
|
||||
|
||||
+184
-249
@@ -80,127 +80,41 @@
|
||||
|
||||
// 筛选区域
|
||||
.filterSection {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.filterRail {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, transparent), transparent),
|
||||
color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff 52%, transparent),
|
||||
0 18px 32px -30px rgba(0, 0, 0, 0.35);
|
||||
align-self: start;
|
||||
0 12px 24px -22px rgba(0, 0, 0, 0.25);
|
||||
|
||||
@include mobile {
|
||||
position: static;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.filterRailHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterRailEyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filterRailHero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterRailHeroTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterRailHeroIcon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
}
|
||||
|
||||
.filterRailHeroIconImage {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.filterRailHeroIconFallback {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filterAllIconWrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 30% 28%,
|
||||
color-mix(in srgb, var(--primary-color) 22%, #ffffff),
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary-color) 8%),
|
||||
color-mix(in srgb, var(--bg-primary) 92%, var(--primary-color) 5%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff 54%, transparent),
|
||||
0 10px 22px -18px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.filterAllIconWrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 7px 7px auto;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
filter: blur(1px);
|
||||
opacity: 0.9;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary-color) 8%),
|
||||
color-mix(in srgb, var(--bg-primary) 92%, var(--primary-color) 5%)
|
||||
);
|
||||
box-shadow: 0 10px 22px -18px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.filterAllIcon {
|
||||
@@ -210,63 +124,6 @@
|
||||
color: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
|
||||
}
|
||||
|
||||
.filterRailHeroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterRailTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.filterRailDescription {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.filterRailMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterRailCount {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 38px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterRailMode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.filterContent {
|
||||
display: flex;
|
||||
@@ -277,24 +134,17 @@
|
||||
|
||||
.filterTags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
gap: 8px;
|
||||
padding: 7px 12px 7px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
@@ -302,28 +152,18 @@
|
||||
background: color-mix(in srgb, var(--filter-surface) 56%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast,
|
||||
box-shadow $transition-fast;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(2px);
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--filter-color) 52%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--filter-surface) 72%, var(--bg-secondary));
|
||||
box-shadow: 0 14px 24px -24px color-mix(in srgb, var(--filter-color) 65%, transparent);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--filter-color) 65%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,9 +174,11 @@
|
||||
color-mix(in srgb, var(--filter-surface) 74%, var(--bg-primary)),
|
||||
color-mix(in srgb, var(--filter-surface) 44%, var(--bg-secondary))
|
||||
);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--filter-color),
|
||||
0 16px 30px -26px color-mix(in srgb, var(--filter-color) 58%, transparent);
|
||||
box-shadow: 0 8px 20px -16px color-mix(in srgb, var(--filter-color) 58%, transparent);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.filterTagLabel {
|
||||
@@ -347,20 +189,20 @@
|
||||
}
|
||||
|
||||
.filterTagIconWrap {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--filter-color) 16%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--filter-surface) 74%, var(--bg-primary));
|
||||
}
|
||||
|
||||
.filterTagIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -381,13 +223,13 @@
|
||||
.filterTagCount {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--filter-color) 14%, transparent);
|
||||
color: var(--filter-color);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
@@ -514,39 +356,15 @@
|
||||
.fileGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
|
||||
}
|
||||
|
||||
.fileGridQuotaManaged {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||||
}
|
||||
|
||||
.fileGridCompact {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
@@ -654,24 +472,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg, rgba(252, 228, 236, 0.18), rgba(252, 228, 236, 0));
|
||||
background-image: linear-gradient(180deg, rgba(251, 236, 228, 0.18), rgba(251, 236, 228, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
background-image: linear-gradient(180deg, rgba(234, 231, 255, 0.18), rgba(234, 231, 255, 0));
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||
background-image: linear-gradient(180deg, rgba(224, 232, 255, 0.2), rgba(224, 232, 255, 0));
|
||||
}
|
||||
|
||||
.kimiCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 244, 229, 0.2), rgba(255, 244, 229, 0));
|
||||
background-image: linear-gradient(180deg, rgba(220, 232, 255, 0.2), rgba(220, 232, 255, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
@@ -830,45 +649,161 @@
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #fdf3d7, #f0d060);
|
||||
border: 1px solid #e0b830;
|
||||
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
|
||||
color: #7a5c00;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 255, 255, 0.96) 0%,
|
||||
rgba(255, 255, 255, 0.72) 18%,
|
||||
rgba(255, 255, 255, 0) 42%
|
||||
),
|
||||
linear-gradient(135deg, #fff9e3 0%, #ffe07f 52%, #e0aa14 100%);
|
||||
border: 1px solid rgba(217, 165, 22, 0.72);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(133, 92, 0, 0.16),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.22) inset,
|
||||
0 0 16px rgba(255, 214, 98, 0.28);
|
||||
color: #6b4b00;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
text-transform: capitalize;
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 0.99 0.95 0.82),
|
||||
color(display-p3 0.97 0.82 0.28)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px -9px;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 237, 158, 0.58) 32%,
|
||||
rgba(255, 215, 91, 0) 72%
|
||||
);
|
||||
border-color: color(display-p3 0.9 0.73 0.12);
|
||||
box-shadow: 0 1px 8px color(display-p3 1 0.84 0 / 0.25);
|
||||
color: color(display-p3 0.5 0.38 0);
|
||||
filter: blur(9px);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.99 0.94) 0%,
|
||||
color(display-p3 1 0.97 0.82 / 0.82) 18%,
|
||||
color(display-p3 1 0.95 0.7 / 0) 42%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 1 0.98 0.88),
|
||||
color(display-p3 0.99 0.86 0.34),
|
||||
color(display-p3 0.92 0.68 0.05)
|
||||
);
|
||||
border-color: color(display-p3 0.9 0.73 0.12 / 0.85);
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 18px color(display-p3 1 0.89 0.2 / 0.36);
|
||||
color: color(display-p3 0.43 0.3 0);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 14px color(display-p3 1 0.89 0.2 / 0.36),
|
||||
0 0 30px #{'color(rec2100-linear 3.6 2.9 0.7 / 0.48)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
#{'color(rec2100-linear 6.5 6.2 5.4 / 0.92)'} 0%,
|
||||
#{'color(rec2100-linear 2.4 2 0.6 / 0.62)'} 34%,
|
||||
#{'color(rec2100-linear 1 0.85 0.12 / 0)'} 76%
|
||||
);
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .premiumPlanValue {
|
||||
background: linear-gradient(135deg, #3d3210, #5a4a18);
|
||||
border-color: #8b7030;
|
||||
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
|
||||
color: #e8c84c;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.32) 0%,
|
||||
rgba(255, 214, 98, 0.18) 18%,
|
||||
rgba(255, 214, 98, 0) 44%
|
||||
),
|
||||
linear-gradient(135deg, #4f3d0b 0%, #6e5510 48%, #8f6d10 100%);
|
||||
border-color: rgba(226, 180, 50, 0.7);
|
||||
box-shadow:
|
||||
0 1px 6px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(255, 220, 120, 0.16) inset,
|
||||
0 0 18px rgba(255, 196, 44, 0.22);
|
||||
color: #fff0a8;
|
||||
text-shadow: 0 0 10px rgba(255, 212, 79, 0.22);
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.42) 0%,
|
||||
rgba(255, 197, 66, 0.28) 34%,
|
||||
rgba(255, 187, 0, 0) 74%
|
||||
);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 0.24 0.2 0.06),
|
||||
color(display-p3 0.38 0.3 0.08)
|
||||
);
|
||||
border-color: color(display-p3 0.58 0.46 0.15);
|
||||
box-shadow: 0 1px 12px color(display-p3 0.8 0.65 0 / 0.2);
|
||||
color: color(display-p3 0.95 0.82 0.25);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.89 0.38 / 0.38) 0%,
|
||||
color(display-p3 1 0.82 0.2 / 0.2) 18%,
|
||||
color(display-p3 1 0.82 0.2 / 0) 44%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 0.33 0.25 0.04),
|
||||
color(display-p3 0.48 0.36 0.08),
|
||||
color(display-p3 0.62 0.46 0.09)
|
||||
);
|
||||
border-color: color(display-p3 0.78 0.6 0.16 / 0.78);
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 20px color(display-p3 1 0.76 0.1 / 0.28);
|
||||
color: color(display-p3 1 0.93 0.58);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 16px color(display-p3 1 0.76 0.1 / 0.28),
|
||||
0 0 28px #{'color(rec2100-linear 4.8 3.5 0.45 / 0.4)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
#{'color(rec2100-linear 5.6 4.1 0.75 / 0.64)'} 0%,
|
||||
#{'color(rec2100-linear 2.2 1.5 0.2 / 0.34)'} 34%,
|
||||
#{'color(rec2100-linear 1.2 0.9 0.06 / 0)'} 74%
|
||||
);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-79
@@ -53,7 +53,9 @@ import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFi
|
||||
import {
|
||||
isAuthFilesSortMode,
|
||||
readAuthFilesUiState,
|
||||
readPersistedAuthFilesCompactMode,
|
||||
writeAuthFilesUiState,
|
||||
writePersistedAuthFilesCompactMode,
|
||||
type AuthFilesSortMode,
|
||||
} from '@/features/authFiles/uiState';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
@@ -88,6 +90,7 @@ export function AuthFilesPage() {
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const [uiStateHydrated, setUiStateHydrated] = useState(false);
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
|
||||
const previousSelectionCountRef = useRef(0);
|
||||
@@ -176,46 +179,58 @@ export function AuthFilesPage() {
|
||||
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = readAuthFilesUiState();
|
||||
if (!persisted) return;
|
||||
const persistedCompactMode = readPersistedAuthFilesCompactMode();
|
||||
if (typeof persistedCompactMode === 'boolean') {
|
||||
setCompactMode(persistedCompactMode);
|
||||
}
|
||||
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.compactMode === 'boolean') {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
const persisted = readAuthFilesUiState();
|
||||
if (persisted) {
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (
|
||||
typeof persistedCompactMode !== 'boolean' &&
|
||||
typeof persisted.compactMode === 'boolean'
|
||||
) {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
}
|
||||
}
|
||||
|
||||
setUiStateHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiStateHydrated) return;
|
||||
|
||||
writeAuthFilesUiState({
|
||||
filter,
|
||||
problemOnly,
|
||||
@@ -227,7 +242,18 @@ export function AuthFilesPage() {
|
||||
compactPageSize: pageSizeByMode.compact,
|
||||
sortMode,
|
||||
});
|
||||
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
|
||||
writePersistedAuthFilesCompactMode(compactMode);
|
||||
}, [
|
||||
compactMode,
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
pageSizeByMode,
|
||||
problemOnly,
|
||||
search,
|
||||
sortMode,
|
||||
uiStateHydrated,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
@@ -342,11 +368,6 @@ export function AuthFilesPage() {
|
||||
return counts;
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const activeFilterLabel =
|
||||
filter === 'all' ? t('auth_files.filter_all') : getTypeLabel(t, String(filter));
|
||||
const activeFilterCount = typeCounts[String(filter)] ?? 0;
|
||||
const activeFilterIcon = getAuthFileIcon(String(filter), resolvedTheme);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
@@ -539,42 +560,7 @@ export function AuthFilesPage() {
|
||||
);
|
||||
|
||||
const renderFilterTags = () => (
|
||||
<aside className={styles.filterRail}>
|
||||
<div className={styles.filterRailHeader}>
|
||||
<span className={styles.filterRailEyebrow}>
|
||||
{t('nav.ai_providers', { defaultValue: 'Providers' })}
|
||||
</span>
|
||||
<div className={styles.filterRailHero}>
|
||||
<div className={styles.filterRailHeroTitle}>
|
||||
{filter === 'all' ? (
|
||||
<span className={`${styles.filterRailHeroIcon} ${styles.filterAllIconWrap}`}>
|
||||
<IconFilterAll className={styles.filterAllIcon} size={22} />
|
||||
</span>
|
||||
) : (
|
||||
<div className={styles.filterRailHeroIcon}>
|
||||
{activeFilterIcon ? (
|
||||
<img src={activeFilterIcon} alt="" className={styles.filterRailHeroIconImage} />
|
||||
) : (
|
||||
<span className={styles.filterRailHeroIconFallback}>
|
||||
{activeFilterLabel.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.filterRailHeroText}>
|
||||
<span className={styles.filterRailTitle}>{activeFilterLabel}</span>
|
||||
<span className={styles.filterRailDescription}>{t('auth_files.title_section')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.filterRailMeta}>
|
||||
<span className={styles.filterRailCount}>{activeFilterCount}</span>
|
||||
{problemOnly && (
|
||||
<span className={styles.filterRailMode}>{t('auth_files.problem_filter_only')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRail}>
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
const isActive = filter === type;
|
||||
@@ -602,7 +588,7 @@ export function AuthFilesPage() {
|
||||
<span className={styles.filterTagLabel}>
|
||||
{type === 'all' ? (
|
||||
<span className={`${styles.filterTagIconWrap} ${styles.filterAllIconWrap}`}>
|
||||
<IconFilterAll className={styles.filterAllIcon} size={18} />
|
||||
<IconFilterAll className={styles.filterAllIcon} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.filterTagIconWrap}>
|
||||
@@ -622,7 +608,7 @@ export function AuthFilesPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
const titleNode = (
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +192,11 @@
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 74%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
@include mobile {
|
||||
padding: 16px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -434,6 +440,7 @@
|
||||
.floatingStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
@@ -449,6 +456,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floatingStatusCompact {
|
||||
max-width: 112px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@include button-reset;
|
||||
position: relative;
|
||||
@@ -503,7 +516,9 @@
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
display: none;
|
||||
max-width: min(180px, 40vw);
|
||||
font-size: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@@ -512,3 +527,16 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.floatingStatus {
|
||||
max-width: min(132px, 38vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.pageHeader,
|
||||
.workspaceShell {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { parse as parseYaml, parseDocument } from 'yaml';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
@@ -17,8 +18,9 @@ import {
|
||||
} from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { DiffModal } from '@/components/config/DiffModal';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
import styles from './ConfigPage.module.scss';
|
||||
|
||||
@@ -36,10 +38,13 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const {
|
||||
visualValues,
|
||||
@@ -79,6 +84,7 @@ export function ConfigPage() {
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
const shouldRenderFloatingActions = isCurrentLayer;
|
||||
const hasVisualModeError = !!visualParseError;
|
||||
const hasVisualValidationErrors =
|
||||
activeTab === 'visual' &&
|
||||
@@ -133,6 +139,24 @@ export function ConfigPage() {
|
||||
setServerYaml(latestContent);
|
||||
setMergedYaml(latestContent);
|
||||
loadVisualValuesFromYaml(latestContent);
|
||||
|
||||
// Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
|
||||
try {
|
||||
useConfigStore.getState().clearCache();
|
||||
await useConfigStore.getState().fetchConfig(undefined, true);
|
||||
} catch (refreshError: unknown) {
|
||||
const message =
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: typeof refreshError === 'string'
|
||||
? refreshError
|
||||
: '';
|
||||
showNotification(
|
||||
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
if (commercialModeChanged) {
|
||||
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
|
||||
@@ -358,7 +382,7 @@ export function ConfigPage() {
|
||||
|
||||
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined' || !shouldRenderFloatingActions) return;
|
||||
|
||||
const actionsEl = floatingActionsRef.current;
|
||||
if (!actionsEl) return;
|
||||
@@ -379,7 +403,7 @@ export function ConfigPage() {
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
document.documentElement.style.removeProperty('--config-action-bar-height');
|
||||
};
|
||||
}, []);
|
||||
}, [shouldRenderFloatingActions]);
|
||||
|
||||
// CodeMirror extensions
|
||||
const extensions = useMemo(
|
||||
@@ -407,6 +431,21 @@ export function ConfigPage() {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getFloatingStatusText = () => {
|
||||
if (!isMobile) return getStatusText();
|
||||
if (disableControls)
|
||||
return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' });
|
||||
if (loading) return t('config_management.status_loading_short', { defaultValue: 'Loading' });
|
||||
if (error) return t('config_management.status_load_failed_short', { defaultValue: 'Failed' });
|
||||
if (hasVisualModeError)
|
||||
return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' });
|
||||
if (hasVisualValidationErrors)
|
||||
return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' });
|
||||
if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' });
|
||||
if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' });
|
||||
return t('config_management.status_loaded_short', { defaultValue: 'Loaded' });
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!isDirty) {
|
||||
void loadConfig();
|
||||
@@ -428,7 +467,13 @@ export function ConfigPage() {
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<div
|
||||
className={`${styles.floatingStatus} ${
|
||||
isMobile ? styles.floatingStatusCompact : ''
|
||||
} ${getStatusClass()}`}
|
||||
>
|
||||
{getFloatingStatusText()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
@@ -622,7 +667,9 @@ export function ConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
{shouldRenderFloatingActions && typeof document !== 'undefined'
|
||||
? createPortal(floatingActions, document.body)
|
||||
: null}
|
||||
<DiffModal
|
||||
open={diffModalOpen}
|
||||
original={serverYaml}
|
||||
|
||||
+149
-28
@@ -151,10 +151,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(252, 228, 236, 0.18),
|
||||
rgba(252, 228, 236, 0));
|
||||
rgba(251, 236, 228, 0.18),
|
||||
rgba(251, 236, 228, 0));
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
@@ -165,20 +166,20 @@
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(255, 243, 224, 0.18),
|
||||
rgba(255, 243, 224, 0));
|
||||
rgba(234, 231, 255, 0.18),
|
||||
rgba(234, 231, 255, 0));
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(231, 239, 255, 0.2),
|
||||
rgba(231, 239, 255, 0));
|
||||
rgba(224, 232, 255, 0.2),
|
||||
rgba(224, 232, 255, 0));
|
||||
}
|
||||
|
||||
.kimiCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(255, 244, 229, 0.2),
|
||||
rgba(255, 244, 229, 0));
|
||||
rgba(220, 232, 255, 0.2),
|
||||
rgba(220, 232, 255, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
@@ -319,41 +320,161 @@
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #fdf3d7, #f0d060);
|
||||
border: 1px solid #e0b830;
|
||||
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
|
||||
color: #7a5c00;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 255, 255, 0.96) 0%,
|
||||
rgba(255, 255, 255, 0.72) 18%,
|
||||
rgba(255, 255, 255, 0) 42%
|
||||
),
|
||||
linear-gradient(135deg, #fff9e3 0%, #ffe07f 52%, #e0aa14 100%);
|
||||
border: 1px solid rgba(217, 165, 22, 0.72);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(133, 92, 0, 0.16),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.22) inset,
|
||||
0 0 16px rgba(255, 214, 98, 0.28);
|
||||
color: #6b4b00;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
text-transform: capitalize;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px -9px;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 237, 158, 0.58) 32%,
|
||||
rgba(255, 215, 91, 0) 72%
|
||||
);
|
||||
filter: blur(9px);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.99 0.95 0.82),
|
||||
color(display-p3 0.97 0.82 0.28));
|
||||
border-color: color(display-p3 0.90 0.73 0.12);
|
||||
box-shadow: 0 1px 8px color(display-p3 1.0 0.84 0.0 / 0.25);
|
||||
color: color(display-p3 0.50 0.38 0.0);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.99 0.94) 0%,
|
||||
color(display-p3 1 0.97 0.82 / 0.82) 18%,
|
||||
color(display-p3 1 0.95 0.7 / 0) 42%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 1 0.98 0.88),
|
||||
color(display-p3 0.99 0.86 0.34),
|
||||
color(display-p3 0.92 0.68 0.05)
|
||||
);
|
||||
border-color: color(display-p3 0.9 0.73 0.12 / 0.85);
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 18px color(display-p3 1 0.89 0.2 / 0.36);
|
||||
color: color(display-p3 0.43 0.3 0);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
|
||||
0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
|
||||
0 0 14px color(display-p3 1 0.89 0.2 / 0.36),
|
||||
0 0 30px #{'color(rec2100-linear 3.6 2.9 0.7 / 0.48)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 22%,
|
||||
#{'color(rec2100-linear 6.5 6.2 5.4 / 0.92)'} 0%,
|
||||
#{'color(rec2100-linear 2.4 2 0.6 / 0.62)'} 34%,
|
||||
#{'color(rec2100-linear 1 0.85 0.12 / 0)'} 76%
|
||||
);
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .premiumPlanValue {
|
||||
background: linear-gradient(135deg, #3d3210, #5a4a18);
|
||||
border-color: #8b7030;
|
||||
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
|
||||
color: #e8c84c;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.32) 0%,
|
||||
rgba(255, 214, 98, 0.18) 18%,
|
||||
rgba(255, 214, 98, 0) 44%
|
||||
),
|
||||
linear-gradient(135deg, #4f3d0b 0%, #6e5510 48%, #8f6d10 100%);
|
||||
border-color: rgba(226, 180, 50, 0.7);
|
||||
box-shadow:
|
||||
0 1px 6px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(255, 220, 120, 0.16) inset,
|
||||
0 0 18px rgba(255, 196, 44, 0.22);
|
||||
color: #fff0a8;
|
||||
text-shadow: 0 0 10px rgba(255, 212, 79, 0.22);
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
rgba(255, 229, 138, 0.42) 0%,
|
||||
rgba(255, 197, 66, 0.28) 34%,
|
||||
rgba(255, 187, 0, 0) 74%
|
||||
);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.24 0.20 0.06),
|
||||
color(display-p3 0.38 0.30 0.08));
|
||||
border-color: color(display-p3 0.58 0.46 0.15);
|
||||
box-shadow: 0 1px 12px color(display-p3 0.80 0.65 0.0 / 0.2);
|
||||
color: color(display-p3 0.95 0.82 0.25);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 24%,
|
||||
color(display-p3 1 0.89 0.38 / 0.38) 0%,
|
||||
color(display-p3 1 0.82 0.2 / 0.2) 18%,
|
||||
color(display-p3 1 0.82 0.2 / 0) 44%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color(display-p3 0.33 0.25 0.04),
|
||||
color(display-p3 0.48 0.36 0.08),
|
||||
color(display-p3 0.62 0.46 0.09)
|
||||
);
|
||||
border-color: color(display-p3 0.78 0.6 0.16 / 0.78);
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 20px color(display-p3 1 0.76 0.1 / 0.28);
|
||||
color: color(display-p3 1 0.93 0.58);
|
||||
}
|
||||
|
||||
@supports (color: #{'color(rec2100-linear 1 1 1)'}) {
|
||||
@media (dynamic-range: high) {
|
||||
dynamic-range-limit: no-limit;
|
||||
box-shadow:
|
||||
0 1px 8px color(display-p3 0 0 0 / 0.32),
|
||||
0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
|
||||
0 0 16px color(display-p3 1 0.76 0.1 / 0.28),
|
||||
0 0 28px #{'color(rec2100-linear 4.8 3.5 0.45 / 0.4)'};
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 18% 24%,
|
||||
#{'color(rec2100-linear 5.6 4.1 0.75 / 0.64)'} 0%,
|
||||
#{'color(rec2100-linear 2.2 1.5 0.2 / 0.34)'} 34%,
|
||||
#{'color(rec2100-linear 1.2 0.9 0.06 / 0)'} 74%
|
||||
);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
if (apiKey) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -149,8 +148,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
/**
|
||||
* 安全存储服务
|
||||
* 本地存储混淆服务(可逆)
|
||||
* 基于原项目 src/utils/secure-storage.js
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界,仅用于避免“肉眼直读”的轻度混淆。
|
||||
*/
|
||||
|
||||
import { encryptData, decryptData } from '@/utils/encryption';
|
||||
import { obfuscateData, deobfuscateData, isObfuscated } from '@/utils/encryption';
|
||||
|
||||
interface StorageOptions {
|
||||
/**
|
||||
* Whether to obfuscate the stored value. This was historically called `encrypt`,
|
||||
* but the implementation is reversible obfuscation, not cryptographic security.
|
||||
*/
|
||||
obfuscate?: boolean;
|
||||
encrypt?: boolean;
|
||||
}
|
||||
|
||||
class SecureStorageService {
|
||||
class ObfuscatedStorageService {
|
||||
/**
|
||||
* 存储数据
|
||||
*/
|
||||
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
this.removeItem(key);
|
||||
@@ -22,7 +29,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
const stringValue = JSON.stringify(value);
|
||||
const storedValue = encrypt ? encryptData(stringValue) : stringValue;
|
||||
const storedValue = obfuscate ? obfuscateData(stringValue) : stringValue;
|
||||
|
||||
localStorage.setItem(key, storedValue);
|
||||
}
|
||||
@@ -31,20 +38,20 @@ class SecureStorageService {
|
||||
* 获取数据
|
||||
*/
|
||||
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
const decrypted = encrypt ? decryptData(raw) : raw;
|
||||
const decrypted = obfuscate ? deobfuscateData(raw) : raw;
|
||||
return JSON.parse(decrypted) as T;
|
||||
} catch {
|
||||
// JSON解析失败,尝试兼容旧的纯字符串数据 (非JSON格式)
|
||||
try {
|
||||
// 如果是加密的,尝试解密后直接返回
|
||||
if (encrypt && raw.startsWith('enc::v1::')) {
|
||||
const decrypted = decryptData(raw);
|
||||
if (obfuscate && isObfuscated(raw)) {
|
||||
const decrypted = deobfuscateData(raw);
|
||||
// 解密后如果还不是JSON,返回原始字符串
|
||||
return decrypted as T;
|
||||
}
|
||||
@@ -108,4 +115,6 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
export const secureStorage = new SecureStorageService();
|
||||
export const obfuscatedStorage = new ObfuscatedStorageService();
|
||||
// Backward-compatible alias (historically named "secureStorage").
|
||||
export const secureStorage = obfuscatedStorage;
|
||||
|
||||
@@ -7,10 +7,11 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { secureStorage } from '@/services/storage/secureStorage';
|
||||
import { obfuscatedStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useUsageStatsStore } from './useUsageStatsStore';
|
||||
import { useModelsStore } from './useModelsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
interface AuthStoreState extends AuthState {
|
||||
@@ -46,13 +47,13 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
if (restoreSessionPromise) return restoreSessionPromise;
|
||||
|
||||
restoreSessionPromise = (async () => {
|
||||
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
obfuscatedStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
|
||||
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const legacyBase =
|
||||
secureStorage.getItem<string>('apiBase') ||
|
||||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = secureStorage.getItem<string>('managementKey');
|
||||
obfuscatedStorage.getItem<string>('apiBase') ||
|
||||
obfuscatedStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = obfuscatedStorage.getItem<string>('managementKey');
|
||||
|
||||
const { apiBase, managementKey, rememberPassword } = get();
|
||||
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
||||
@@ -94,6 +95,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
// 配置 API 客户端
|
||||
apiClient.setConfig({
|
||||
@@ -138,6 +140,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
restoreSessionPromise = null;
|
||||
useConfigStore.getState().clearCache();
|
||||
useUsageStatsStore.getState().clearUsageStats();
|
||||
useModelsStore.getState().clearCache();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
apiBase: '',
|
||||
@@ -197,14 +200,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
name: STORAGE_KEY_AUTH,
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: (name) => {
|
||||
const data = secureStorage.getItem<AuthStoreState>(name);
|
||||
const data = obfuscatedStorage.getItem<AuthStoreState>(name);
|
||||
return data ? JSON.stringify(data) : null;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
secureStorage.setItem(name, JSON.parse(value));
|
||||
obfuscatedStorage.setItem(name, JSON.parse(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
secureStorage.removeItem(name);
|
||||
obfuscatedStorage.removeItem(name);
|
||||
}
|
||||
})),
|
||||
partialize: (state) => ({
|
||||
|
||||
@@ -267,7 +267,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
// 同时清除完整配置缓存
|
||||
newCache.delete('__full__');
|
||||
|
||||
set({ cache: newCache });
|
||||
// Section-level invalidation usually follows an optimistic write path. Invalidate any in-flight
|
||||
// full fetch so stale responses can't overwrite newer local changes.
|
||||
configRequestToken += 1;
|
||||
inFlightConfigRequest = null;
|
||||
|
||||
set({ cache: newCache, loading: false, error: null });
|
||||
return;
|
||||
} else {
|
||||
newCache.clear();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ModelsCache {
|
||||
data: ModelInfo[];
|
||||
timestamp: number;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface ModelsState {
|
||||
@@ -21,7 +22,7 @@ interface ModelsState {
|
||||
|
||||
fetchModels: (apiBase: string, apiKey?: string, forceRefresh?: boolean) => Promise<ModelInfo[]>;
|
||||
clearCache: () => void;
|
||||
isCacheValid: (apiBase: string) => boolean;
|
||||
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
|
||||
}
|
||||
|
||||
export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
@@ -32,9 +33,10 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
|
||||
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
|
||||
const { cache, isCacheValid } = get();
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(apiBase) && cache) {
|
||||
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
|
||||
set({ models: cache.data, error: null });
|
||||
return cache.data;
|
||||
}
|
||||
@@ -42,13 +44,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKey);
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
|
||||
const now = Date.now();
|
||||
|
||||
set({
|
||||
models: list,
|
||||
loading: false,
|
||||
cache: { data: list, timestamp: now, apiBase }
|
||||
cache: { data: list, timestamp: now, apiBase, apiKey: apiKeyScope }
|
||||
});
|
||||
|
||||
return list;
|
||||
@@ -68,10 +70,12 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ cache: null, models: [] });
|
||||
},
|
||||
|
||||
isCacheValid: (apiBase) => {
|
||||
isCacheValid: (apiBase, apiKey) => {
|
||||
const { cache } = get();
|
||||
if (!cache) return false;
|
||||
if (cache.apiBase !== apiBase) return false;
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
if ((cache.apiKey || '') !== apiKeyScope) return false;
|
||||
return Date.now() - cache.timestamp < CACHE_EXPIRY_MS;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -411,6 +411,7 @@ textarea {
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: $shadow-lg;
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -523,6 +524,49 @@ textarea {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.modal-overlay {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-height: calc(100vh - #{$spacing-md * 2});
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal {
|
||||
max-height: calc(100dvh - #{$spacing-md * 2});
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: $spacing-md;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: $spacing-md;
|
||||
max-height: min(60vh, calc(100vh - 180px));
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal-body {
|
||||
max-height: min(60dvh, calc(100dvh - 180px));
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: $spacing-md;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.request-log-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+14
-7
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 加密工具函数
|
||||
* 本地存储混淆工具函数(可逆)
|
||||
* 从原项目 src/utils/secure-storage.js 迁移
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界。浏览器端长期持久化的密钥仍应视为可被读取。
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = 'enc::v1::';
|
||||
@@ -26,7 +28,7 @@ function getKeyBytes(): Uint8Array {
|
||||
const ua = navigator.userAgent;
|
||||
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
|
||||
} catch (error) {
|
||||
console.warn('Encryption fallback to simple key:', error);
|
||||
console.warn('Obfuscation fallback to simple key:', error);
|
||||
cachedKeyBytes = encodeText(SECRET_SALT);
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ function fromBase64(base64: string): Uint8Array {
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
export function encryptData(value: string): string {
|
||||
export function obfuscateData(value: string): string {
|
||||
if (!value) return value;
|
||||
|
||||
try {
|
||||
@@ -69,7 +71,7 @@ export function encryptData(value: string): string {
|
||||
const encrypted = xorBytes(encodeText(value), keyBytes);
|
||||
return `${ENC_PREFIX}${toBase64(encrypted)}`;
|
||||
} catch (error) {
|
||||
console.warn('Encryption failed, fallback to plaintext:', error);
|
||||
console.warn('Obfuscation failed, fallback to plaintext:', error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export function encryptData(value: string): string {
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
export function decryptData(payload: string): string {
|
||||
export function deobfuscateData(payload: string): string {
|
||||
if (!payload || !payload.startsWith(ENC_PREFIX)) {
|
||||
return payload;
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export function decryptData(payload: string): string {
|
||||
const decrypted = xorBytes(encrypted, getKeyBytes());
|
||||
return decodeText(decrypted);
|
||||
} catch (error) {
|
||||
console.warn('Decryption failed, return as-is:', error);
|
||||
console.warn('Deobfuscation failed, return as-is:', error);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +98,11 @@ export function decryptData(payload: string): string {
|
||||
/**
|
||||
* 检查是否已加密
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
export function isObfuscated(value: string): boolean {
|
||||
return value?.startsWith(ENC_PREFIX) || false;
|
||||
}
|
||||
|
||||
// Backward-compatible aliases (this module was historically named "encryption").
|
||||
export const encryptData = obfuscateData;
|
||||
export const decryptData = deobfuscateData;
|
||||
export const isEncrypted = isObfuscated;
|
||||
|
||||
@@ -31,6 +31,12 @@ export function buildHeaderObject(input?: HeaderEntry[] | Record<string, string
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function hasHeader(headers: Record<string, unknown> | null | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
const target = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||
}
|
||||
|
||||
export function headersToEntries(headers?: Record<string, string | undefined | null>): HeaderEntry[] {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
|
||||
@@ -8,18 +8,18 @@ import type {
|
||||
TypeColorSet,
|
||||
} from '@/types';
|
||||
|
||||
// Theme colors for type badges
|
||||
// Theme colors for type badges — 与 authFiles/constants.ts 保持同步
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
light: { bg: '#ede5fd', text: '#5530c7' },
|
||||
dark: { bg: '#36208a', text: '#b5a3f0' },
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
light: { bg: '#e0e8ff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
aistudio: {
|
||||
@@ -27,24 +27,28 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||
light: { bg: '#fbece4', text: '#c05621' },
|
||||
dark: { bg: '#5e2c14', text: '#e8a882' },
|
||||
},
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
light: { bg: '#eae7ff', text: '#3538d4' },
|
||||
dark: { bg: '#262395', text: '#b5b0ff' },
|
||||
},
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||
light: { bg: '#dce8ff', text: '#0560cf' },
|
||||
dark: { bg: '#003880', text: '#70b5ff' },
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||
light: { bg: '#f5e3fc', text: '#9025c8' },
|
||||
dark: { bg: '#521490', text: '#d49cf5' },
|
||||
},
|
||||
vertex: {
|
||||
light: { bg: '#e4edfd', text: '#2b5fbc' },
|
||||
dark: { bg: '#1a3d80', text: '#89b3f7' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
|
||||
Reference in New Issue
Block a user