Compare commits

...

26 Commits

38 changed files with 2678 additions and 1097 deletions
@@ -72,6 +72,61 @@
}
}
.expandableInputWrapper {
position: relative;
display: flex;
align-items: flex-start;
min-width: 0;
flex: 1;
}
.expandableInputWrapper > .expandableTextarea,
.expandableInputWrapper > :global(.input) {
flex: 1;
min-width: 0;
padding-right: 28px;
}
.expandableTextarea {
resize: none;
min-height: 60px;
overflow: hidden;
line-height: 1.5;
padding-right: 32px;
}
.expandableToggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 10px;
line-height: 1;
padding: 2px;
color: var(--text-secondary, #999);
opacity: 0.5;
transition: opacity 0.15s;
z-index: 1;
&:hover {
opacity: 1;
}
&:disabled {
cursor: default;
opacity: 0.35;
}
}
.expandableInputExpanded .expandableToggle {
top: 8px;
transform: none;
right: 14px;
}
.overview {
position: relative;
overflow: hidden;
@@ -229,23 +284,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 +586,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 +872,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 +892,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;
}
}
+208 -35
View File
@@ -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>
);
}
@@ -1,4 +1,4 @@
import { memo, useId, useMemo, useState } from 'react';
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -23,6 +23,109 @@ import {
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
/** Minimum character count before the expand/collapse toggle appears. */
const EXPAND_THRESHOLD = 30;
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
function ExpandableInput({
value,
placeholder,
ariaLabel,
disabled,
className,
onChange,
}: {
value: string;
placeholder?: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResize = useCallback((el: HTMLTextAreaElement) => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Strip newlines — these fields are single-line identifiers/paths that
// would break YAML serialization if they contained line breaks.
const sanitized = e.target.value.replace(/[\r\n]/g, '');
onChange(sanitized);
// autoResize is handled by useLayoutEffect after React syncs the
// sanitized value back to the DOM — calling it here would measure
// stale content.
};
// Resize synchronously before paint to avoid visual flicker.
useLayoutEffect(() => {
if (!collapsed && textareaRef.current) {
autoResize(textareaRef.current);
}
}, [collapsed, value, autoResize]);
if (collapsed) {
return (
<div className={styles.expandableInputWrapper}>
<input
className={`input ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
disabled={disabled}
/>
{value.length > EXPAND_THRESHOLD && (
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => {
setCollapsed(false);
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}}
title={t('common.expand')}
aria-label={t('common.expand')}
>
</button>
)}
</div>
);
}
return (
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
<textarea
ref={textareaRef}
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={handleChange}
disabled={disabled}
rows={2}
/>
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => setCollapsed(true)}
title={t('common.collapse')}
aria-label={t('common.collapse')}
>
</button>
</div>
);
}
function getValidationMessage(
t: ReturnType<typeof useTranslation>['t'],
errorCode?: PayloadParamValidationErrorCode
@@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({
<div className={styles.stringList}>
{items.map((item, index) => (
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
<input
className="input"
<ExpandableInput
placeholder={placeholder}
aria-label={inputAriaLabel ?? placeholder}
ariaLabel={inputAriaLabel ?? placeholder}
value={item}
onChange={(e) => updateItem(index, e.target.value)}
onChange={(nextValue) => updateItem(index, nextValue)}
disabled={disabled}
style={{ flex: 1 }}
/>
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
{t('config_management.visual.common.delete')}
@@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
}
return (
<input
className="input"
<ExpandableInput
placeholder={getValuePlaceholder(param.valueType)}
aria-label={t('config_management.visual.payload_rules.param_value')}
ariaLabel={t('config_management.visual.payload_rules.param_value')}
value={param.value}
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
disabled={disabled}
/>
);
@@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
})
}
/>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
</>
) : (
<>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
@@ -629,12 +727,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
return (
<div key={param.id} className={styles.payloadRuleParamGroup}>
<div className={styles.payloadRuleParamRow}>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.json_path')}
aria-label={t('config_management.visual.payload_rules.json_path')}
ariaLabel={t('config_management.visual.payload_rules.json_path')}
value={param.path}
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
disabled={disabled}
/>
{rawJsonValues ? null : (
@@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
</div>
{rule.models.map((model, modelIndex) => (
<div key={model.id} className={styles.payloadFilterModelRow}>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
+1 -1
View File
@@ -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}
+2 -5
View File
@@ -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
View File
@@ -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))}
</>
);
}
+1 -1
View File
@@ -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>
);
}
+150 -60
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
const TOOLTIP_OFFSET = 8;
const TOOLTIP_SAFE_WIDTH = 180;
const TOOLTIP_SAFE_HEIGHT = 72;
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
type TooltipVerticalPosition = 'above' | 'below';
interface ActiveTooltipState {
idx: number;
anchorEl: HTMLDivElement;
horizontal: TooltipHorizontalPosition;
vertical: TooltipVerticalPosition;
left: number;
top: number;
transform: string;
}
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const buildTooltipState = useCallback(
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
const rect = anchorEl.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
let horizontal: TooltipHorizontalPosition = 'center';
let left = centerX;
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'left';
left = rect.left;
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'right';
left = rect.right;
}
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
const translateY = vertical === 'below' ? '0' : '-100%';
return {
idx,
anchorEl,
horizontal,
vertical,
left: Math.round(left),
top: Math.round(top),
transform: `translate(${translateX}, ${translateY})`,
};
},
[]
);
useEffect(() => {
if (!activeTooltip) return;
const updateTooltipPosition = () => {
if (!document.body.contains(activeTooltip.anchorEl)) {
setActiveTooltip(null);
return;
}
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
};
window.addEventListener('resize', updateTooltipPosition);
window.addEventListener('scroll', updateTooltipPosition, true);
return () => {
window.removeEventListener('resize', updateTooltipPosition);
window.removeEventListener('scroll', updateTooltipPosition, true);
};
}, [activeTooltip, buildTooltipState]);
const openTooltip = useCallback(
(idx: number, anchorEl: HTMLDivElement) => {
setActiveTooltip(buildTooltipState(idx, anchorEl));
},
[buildTooltipState]
);
const handlePointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'mouse') {
openTooltip(idx, e.currentTarget);
}
},
[openTooltip]
);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) =>
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
);
}
},
[buildTooltipState]
);
const getTooltipPositionClass = (idx: number): string => {
const col = Math.floor(idx / healthData.rows);
if (col <= 2) return styles.healthTooltipLeft;
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
return '';
};
const getTooltipVerticalClass = (idx: number): string => {
const row = idx % healthData.rows;
if (row <= 1) return styles.healthTooltipBelow;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx);
const vertClass = getTooltipVerticalClass(idx);
const posClass =
tooltipState.horizontal === 'left'
? styles.healthTooltipLeft
: tooltipState.horizontal === 'right'
? styles.healthTooltipRight
: '';
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
return (
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
const tooltip = (
<div
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
style={{
position: 'fixed',
left: `${tooltipState.left}px`,
top: `${tooltipState.top}px`,
bottom: 'auto',
right: 'auto',
transform: tooltipState.transform,
}}
>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={styles.healthTooltipSuccess}>
{t('status_bar.success_short')} {detail.success}
</span>
<span className={styles.healthTooltipFailure}>
{t('status_bar.failure_short')} {detail.failure}
</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
)}
</div>
);
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
};
const rateClass = !hasData
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
</div>
</div>
<div className={styles.healthGridScroller}>
<div
className={styles.healthGrid}
ref={gridRef}
>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
<div className={styles.healthGrid} ref={gridRef}>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip?.idx === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
return (
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
+26 -12
View File
@@ -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: 亮蓝 #027AFFK字 + 蓝色圆点)
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' },
+80 -110
View File
@@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
setSelectedFiles(new Set());
}, []);
const applyDeletedFiles = useCallback((names: string[]) => {
const deletedNames = Array.from(
new Set(
names
.map((name) => name.trim())
.filter(Boolean)
)
);
if (deletedNames.length === 0) return;
const deletedSet = new Set(deletedNames);
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
}, []);
useEffect(() => {
if (selectedFiles.size === 0) return;
const existingNames = new Set(files.map((file) => file.name));
@@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
}
setUploading(true);
let successCount = 0;
const failed: { name: string; message: string }[] = [];
try {
const result = await authFilesApi.uploadFiles(validFiles);
const successCount = result.uploaded;
for (const file of validFiles) {
try {
await authFilesApi.upload(file);
successCount++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
failed.push({ name: file.name, message: errorMessage });
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
result.failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
}
}
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
if (result.failed.length > 0) {
const details = result.failed
.map((item) => `${item.name}: ${item.error}`)
.join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
} finally {
setUploading(false);
event.target.value = '';
}
if (failed.length > 0) {
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
setUploading(false);
event.target.value = '';
},
[loadFiles, refreshKeyStats, showNotification, t]
);
@@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
const result = await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
setSelectedFiles((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
});
applyDeletedFiles(result.files.length > 0 ? result.files : [name]);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
@@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
},
});
},
[showConfirmation, showNotification, t]
[applyDeletedFiles, showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
@@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
return;
}
let success = 0;
let failed = 0;
const deletedNames: string[] = [];
const result = await authFilesApi.deleteFiles(
filesToDelete.map((file) => file.name)
);
const success = result.deleted;
const failed = result.failed.length;
for (const file of filesToDelete) {
try {
await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deletedNames);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
applyDeletedFiles(result.files);
if (failed === 0 && isProblemOnly) {
showNotification(
@@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
},
});
},
[deselectAll, files, showConfirmation, showNotification, t]
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
@@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const results = await Promise.allSettled(
uniqueNames.map((name) => authFilesApi.deleteFile(name))
);
try {
const result = await authFilesApi.deleteFiles(uniqueNames);
applyDeletedFiles(result.files);
const deleted: string[] = [];
let failCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
deleted.push(uniqueNames[index]);
if (result.failed.length === 0) {
showNotification(
`${t('auth_files.delete_all_success')} (${result.deleted})`,
'success'
);
} else {
failCount++;
showNotification(
t('auth_files.delete_filtered_partial', {
success: result.deleted,
failed: result.failed.length,
type: t('auth_files.filter_all'),
}),
'warning'
);
}
});
if (deleted.length > 0) {
const deletedSet = new Set(deleted);
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
}
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deleted);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
if (failCount === 0) {
showNotification(
`${t('auth_files.delete_all_success')} (${deleted.length})`,
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', {
success: deleted.length,
failed: failCount,
type: t('auth_files.filter_all'),
}),
'warning'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
}
},
});
},
[showConfirmation, showNotification, t]
[applyDeletedFiles, showConfirmation, showNotification, t]
);
return {
+22
View File
@@ -7,6 +7,7 @@ export type AuthFilesUiState = {
problemOnly?: boolean;
compactMode?: boolean;
search?: string;
regexSearchMode?: boolean;
page?: number;
pageSize?: number;
regularPageSize?: number;
@@ -15,6 +16,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 +42,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
}
};
+17
View File
@@ -41,6 +41,8 @@
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy",
"expand": "Expand",
"collapse": "Collapse",
"status": "Status",
"action": "Action",
"custom_headers_label": "Custom Headers",
@@ -218,6 +220,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.",
@@ -493,10 +498,14 @@
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"search_regex_placeholder": "Match name, type, or provider with a regex",
"search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)",
"search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"display_options_label": "Display options",
"compact_mode_label": "Compact mode",
"regex_search_mode_label": "Regex mode",
"sort_label": "Sort",
"sort_default": "Default",
"sort_az": "A-Z Name",
@@ -1130,16 +1139,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}}",
+17
View File
@@ -41,6 +41,8 @@
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
"copy": "Копировать",
"expand": "Развернуть",
"collapse": "Свернуть",
"status": "Статус",
"action": "Действие",
"custom_headers_label": "Пользовательские заголовки",
@@ -218,6 +220,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": "Нет подходящих моделей. Попробуйте другой запрос.",
@@ -493,10 +498,14 @@
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
"search_label": "Поиск конфигов",
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex",
"search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)",
"search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"display_options_label": "Параметры отображения",
"compact_mode_label": "Компактный режим",
"regex_search_mode_label": "Режим regex",
"sort_label": "Сортировка",
"sort_default": "По умолчанию",
"sort_az": "A-Z Имя",
@@ -1133,16 +1142,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}}",
+17
View File
@@ -41,6 +41,8 @@
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"expand": "展开",
"collapse": "收起",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头",
@@ -218,6 +220,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": "没有匹配的模型,请更换关键字试试。",
@@ -493,10 +498,14 @@
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方",
"search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)",
"search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"display_options_label": "显示选项",
"compact_mode_label": "简略模式",
"regex_search_mode_label": "正则模式",
"sort_label": "排序",
"sort_default": "默认",
"sort_az": "A-Z 名称",
@@ -1130,16 +1139,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}}",
+90 -19
View File
@@ -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>
+131 -29
View File
@@ -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>
+141 -32
View File
@@ -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>
+2 -2
View File
@@ -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()}`;
}
+92 -21
View File
@@ -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>
+34 -3
View File
@@ -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 & {
File diff suppressed because it is too large Load Diff
+145 -86
View File
@@ -24,6 +24,7 @@ import { IconFilterAll } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety';
import {
MAX_CARD_PAGE_SIZE,
MIN_CARD_PAGE_SIZE,
@@ -53,7 +54,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';
@@ -65,6 +68,7 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120;
export function AuthFilesPage() {
const { t } = useTranslation();
@@ -79,6 +83,7 @@ export function AuthFilesPage() {
const [problemOnly, setProblemOnly] = useState(false);
const [compactMode, setCompactMode] = useState(false);
const [search, setSearch] = useState('');
const [regexSearchMode, setRegexSearchMode] = useState(false);
const [page, setPage] = useState(1);
const [pageSizeByMode, setPageSizeByMode] = useState({
regular: DEFAULT_REGULAR_PAGE_SIZE,
@@ -88,6 +93,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,58 +182,86 @@ 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.regexSearchMode === 'boolean') {
setRegexSearchMode(persisted.regexSearchMode);
}
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,
compactMode,
search,
regexSearchMode,
page,
pageSize,
regularPageSize: pageSizeByMode.regular,
compactPageSize: pageSizeByMode.compact,
sortMode,
});
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
writePersistedAuthFilesCompactMode(compactMode);
}, [
compactMode,
filter,
page,
pageSize,
pageSizeByMode,
problemOnly,
regexSearchMode,
search,
sortMode,
uiStateHydrated,
]);
useEffect(() => {
setPageSizeInput(String(pageSize));
@@ -342,23 +376,63 @@ 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 normalizedSearch = search.trim();
const { regexSearch, regexSearchErrorKey } = useMemo(() => {
if (!regexSearchMode || !normalizedSearch) {
return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined };
}
if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
if (isLikelyUnsafeJsRegex(normalizedSearch)) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_unsafe',
};
}
try {
return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined };
} catch {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
}, [normalizedSearch, regexSearchMode]);
const searchError = regexSearchErrorKey
? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH })
: undefined;
const filtered = useMemo(() => {
return filesMatchingProblemFilter.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const term = search.trim().toLowerCase();
const matchSearch =
!term ||
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term);
const matchSearch = (() => {
if (!normalizedSearch) return true;
if (!regexSearchMode) {
const term = normalizedSearch.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term)
);
}
if (!regexSearch) return false;
return [item.name, item.type, item.provider].some((value) =>
regexSearch.test((value || '').toString())
);
})();
return matchType && matchSearch;
});
}, [filesMatchingProblemFilter, filter, search]);
}, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]);
const sorted = useMemo(() => {
const copy = [...filtered];
@@ -539,42 +613,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 +641,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 +661,7 @@ export function AuthFilesPage() {
);
})}
</div>
</aside>
</div>
);
const titleNode = (
@@ -705,7 +744,12 @@ export function AuthFilesPage() {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
placeholder={
regexSearchMode
? t('auth_files.search_regex_placeholder')
: t('auth_files.search_placeholder')
}
error={searchError}
/>
</div>
<div className={styles.filterItem}>
@@ -767,6 +811,21 @@ export function AuthFilesPage() {
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={regexSearchMode}
onChange={(value) => {
setRegexSearchMode(value);
setPage(1);
}}
ariaLabel={t('auth_files.regex_search_mode_label')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.regex_search_mode_label')}
</span>
}
/>
</div>
</div>
</div>
</div>
+29 -1
View File
@@ -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;
}
}
+52 -5
View File
@@ -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
View File
@@ -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;
}
}
}
}
+10 -7
View File
@@ -1112,17 +1112,20 @@
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
background-color: var(--floating-surface, #ffffff);
background-image: none;
border: 1px solid var(--floating-border, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
opacity: 1;
background-clip: padding-box;
&::after {
content: '';
@@ -1131,7 +1134,7 @@
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
border-top-color: var(--floating-surface, #ffffff);
}
&::before {
@@ -1141,7 +1144,7 @@
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
border-top-color: var(--floating-border, #e5e7eb);
}
}
@@ -1154,14 +1157,14 @@
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--bg-primary, #fff);
border-bottom-color: var(--floating-surface, #ffffff);
}
&::before {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--border-secondary, #e5e7eb);
border-bottom-color: var(--floating-border, #e5e7eb);
}
}
+174 -4
View File
@@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
type AuthFileEntry = AuthFilesResponse['files'][number];
type AuthFileBatchFailure = { name: string; error: string };
type AuthFileBatchUploadResponse = {
status?: string;
uploaded?: number;
files?: unknown;
failed?: unknown;
};
type AuthFileBatchDeleteResponse = {
status?: string;
deleted?: number;
files?: unknown;
failed?: unknown;
};
type AuthFileBatchUploadResult = {
status: string;
uploaded: number;
files: string[];
failed: AuthFileBatchFailure[];
};
type AuthFileBatchDeleteResult = {
status: string;
deleted: number;
files: string[];
failed: AuthFileBatchFailure[];
};
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
@@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => {
return undefined;
};
const normalizeRequestedAuthFileNames = (names: string[]): string[] => {
const seen = new Set<string>();
const normalized: string[] = [];
names.forEach((name) => {
const trimmed = String(name ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
normalized.push(trimmed);
});
return normalized;
};
const normalizeBatchFileNames = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? '')));
};
const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
if (!Array.isArray(value)) return [];
return value.reduce<AuthFileBatchFailure[]>((result, item) => {
if (!item || typeof item !== 'object') return result;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? '').trim();
const error =
typeof entry.error === 'string'
? entry.error.trim()
: typeof entry.message === 'string'
? entry.message.trim()
: '';
if (!name && !error) return result;
result.push({ name, error: error || 'Unknown error' });
return result;
}, []);
};
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
const failedNames = new Set(
failed
.map((entry) => entry.name.trim())
.filter(Boolean)
);
if (failedNames.size === 0) {
return [...requestedNames];
}
return requestedNames.filter((name) => !failedNames.has(name));
};
const normalizeBatchUploadResponse = (
payload: AuthFileBatchUploadResponse | undefined,
requestedNames: string[]
): AuthFileBatchUploadResult => {
const failed = normalizeBatchFailures(payload?.failed);
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
const uploaded =
typeof payload?.uploaded === 'number'
? payload.uploaded
: uploadedFilesFromPayload.length > 0
? uploadedFilesFromPayload.length
: requestedNames.length === 1 && failed.length === 0
? 1
: 0;
let uploadedFiles = uploadedFilesFromPayload;
if (uploadedFiles.length === 0 && uploaded > 0) {
if (failed.length === 0 && uploaded === requestedNames.length) {
uploadedFiles = [...requestedNames];
} else {
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
if (derivedNames.length === uploaded) {
uploadedFiles = derivedNames;
}
}
}
return {
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
uploaded,
files: uploadedFiles,
failed,
};
};
const normalizeBatchDeleteResponse = (
payload: AuthFileBatchDeleteResponse | undefined,
requestedNames: string[]
): AuthFileBatchDeleteResult => {
const failed = normalizeBatchFailures(payload?.failed);
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
const deleted =
typeof payload?.deleted === 'number'
? payload.deleted
: deletedFilesFromPayload.length > 0
? deletedFilesFromPayload.length
: requestedNames.length === 1 && failed.length === 0
? 1
: 0;
let deletedFiles = deletedFilesFromPayload;
if (deletedFiles.length === 0 && deleted > 0) {
if (failed.length === 0 && deleted === requestedNames.length) {
deletedFiles = [...requestedNames];
} else {
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
if (derivedNames.length === deleted) {
deletedFiles = derivedNames;
}
}
}
return {
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
deleted,
files: deletedFiles,
failed,
};
};
const readTextField = (entry: AuthFileEntry, key: string): string => {
const value = entry[key];
return typeof value === 'string' ? value.trim() : '';
@@ -252,13 +400,35 @@ export const authFilesApi = {
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => {
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
const requestedNames = files.map((file) => file.name);
if (requestedNames.length === 0) {
return { status: 'ok', uploaded: 0, files: [], failed: [] };
}
const formData = new FormData();
formData.append('file', file, file.name);
return apiClient.postForm('/auth-files', formData);
files.forEach((file) => {
formData.append('file', file, file.name);
});
const payload = await apiClient.postForm<AuthFileBatchUploadResponse>('/auth-files', formData);
return normalizeBatchUploadResponse(payload, requestedNames);
},
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
upload: (file: File) => authFilesApi.uploadFiles([file]),
deleteFiles: async (names: string[]): Promise<AuthFileBatchDeleteResult> => {
const requestedNames = normalizeRequestedAuthFileNames(names);
if (requestedNames.length === 0) {
return { status: 'ok', deleted: 0, files: [], failed: [] };
}
const payload = await apiClient.delete<AuthFileBatchDeleteResponse>('/auth-files', {
data: { names: requestedNames },
});
return normalizeBatchDeleteResponse(payload, requestedNames);
},
deleteFile: (name: string) => authFilesApi.deleteFiles([name]),
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
+3 -5
View File
@@ -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}`;
}
+19 -10
View File
@@ -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;
+11 -8
View File
@@ -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) => ({
+6 -1
View File
@@ -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();
+9 -5
View File
@@ -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;
}
}));
+44
View File
@@ -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;
+9
View File
@@ -11,6 +11,9 @@
--bg-hover: var(--bg-tertiary);
--bg-quinary: #f6f4ee;
--bg-error-light: rgba(198, 87, 70, 0.1);
--floating-surface: #fffdf9;
--floating-border: #d8d3ca;
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
--text-primary: #2d2a26;
--text-secondary: #6d6760;
@@ -64,6 +67,9 @@
--bg-hover: var(--bg-tertiary);
--bg-quinary: #ffffff;
--bg-error-light: rgba(198, 87, 70, 0.08);
--floating-surface: #ffffff;
--floating-border: #d9d9d9;
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
--text-primary: #2d2a26;
--text-secondary: #6d6760;
@@ -118,6 +124,9 @@
--bg-hover: #2e2a26;
--bg-quinary: #191714;
--bg-error-light: rgba(198, 87, 70, 0.18);
--floating-surface: #2a2723;
--floating-border: #4a443d;
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
--text-primary: #f6f4f1;
--text-secondary: #c9c3bb;
+14 -7
View File
@@ -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;
+6
View File
@@ -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)
+16 -12
View File
@@ -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' },
+166
View File
@@ -0,0 +1,166 @@
type GroupState = {
hasInnerVariableQuantifier: boolean;
hasAlternation: boolean;
justOpened: boolean;
};
type Quantifier = {
length: number;
min: number;
max: number | null; // null means unbounded
variable: boolean; // can match multiple lengths for the repeated token
};
const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9;
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9';
const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => {
if (pattern[index] !== '{') return null;
let i = index + 1;
let minStr = '';
while (isDigit(pattern[i])) {
minStr += pattern[i];
i += 1;
}
if (minStr.length === 0) return null;
const min = Number(minStr);
let max: number | null = min;
if (pattern[i] === ',') {
i += 1;
let maxStr = '';
while (isDigit(pattern[i])) {
maxStr += pattern[i];
i += 1;
}
max = maxStr.length === 0 ? null : Number(maxStr);
}
if (pattern[i] !== '}') return null;
const variable = max === null || max !== min;
return { length: i - index + 1, min, max, variable };
};
const readQuantifier = (pattern: string, index: number): Quantifier | null => {
const ch = pattern[index];
if (ch === '*') return { length: 1, min: 0, max: null, variable: true };
if (ch === '+') return { length: 1, min: 1, max: null, variable: true };
if (ch === '?') return { length: 1, min: 0, max: 1, variable: true };
if (ch !== '{') return null;
return readBraceQuantifier(pattern, index);
};
/**
* Heuristic safety check for user-supplied JS regex patterns.
*
* Goal: prevent patterns that are very likely to cause catastrophic backtracking
* (e.g. `^(a+)+$`) from running on the main thread.
*
* Notes:
* - This is intentionally conservative but tries to avoid blocking common safe patterns.
* - We do not execute the regex here; only scan the pattern string.
*/
export function isLikelyUnsafeJsRegex(pattern: string): boolean {
let inCharClass = false;
const groupStack: GroupState[] = [
{ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false },
];
const markInnerVariableQuantifier = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasInnerVariableQuantifier = true;
}
};
const markAlternation = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasAlternation = true;
}
};
const isOuterRepeatRisky = (q: Quantifier): boolean => {
// If it cannot repeat more than once, it's not a "repeat group" in the sense that
// triggers catastrophic backtracking (e.g. `(a+)?`).
const max = q.max ?? Number.POSITIVE_INFINITY;
if (max <= 1) return false;
// Unbounded repetition is the main hazard: `*`, `+`, `{m,}`.
if (q.max === null) return true;
// Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier.
return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND;
};
for (let i = 0; i < pattern.length; i += 1) {
const ch = pattern[i];
// Reset "justOpened" once we move past the first token inside the group.
const top = groupStack[groupStack.length - 1];
if (top.justOpened) {
top.justOpened = false;
// `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers.
if (ch === '?') continue;
}
if (ch === '\\') {
const next = pattern[i + 1];
// Backreferences often make backtracking far worse.
if (next && next >= '1' && next <= '9') return true;
// Named backreference: \k<name>
if (next === 'k' && pattern[i + 2] === '<') return true;
// Skip escaped character.
i += 1;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true });
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return true; // unbalanced, treat as unsafe
const q = readQuantifier(pattern, i + 1);
if (
q &&
isOuterRepeatRisky(q) &&
(group.hasInnerVariableQuantifier || group.hasAlternation)
) {
return true;
}
continue;
}
if (ch === '|') {
// Alternation inside a repeated group is frequently a backtracking hotspot.
markAlternation();
continue;
}
const q = readQuantifier(pattern, i);
if (q) {
if (q.variable) markInnerVariableQuantifier();
i += q.length - 1;
continue;
}
}
return false;
}