Compare commits

...

44 Commits

62 changed files with 3221 additions and 1295 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" translate="no" class="notranslate">
<head>
<meta charset="UTF-8" />
<meta name="google" content="notranslate" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLI Proxy API Management Center</title>
+8 -2
View File
@@ -2,7 +2,11 @@ import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react
import { useLocation, type Location } from 'react-router-dom';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import {
PAGE_TRANSITION_LAYER_CONTEXT_VALUES,
PageTransitionLayerContext,
type LayerStatus,
} from './PageTransitionLayer';
import './PageTransition.scss';
interface PageTransitionProps {
@@ -386,7 +390,9 @@ export function PageTransition({
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
<PageTransitionLayerContext.Provider
value={PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status]}
>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
+8 -2
View File
@@ -2,14 +2,20 @@ import { createContext, useContext } from 'react';
export type LayerStatus = 'current' | 'exiting' | 'stacked';
type PageTransitionLayerContextValue = {
export type PageTransitionLayerContextValue = {
status: LayerStatus;
isCurrentLayer: boolean;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<LayerStatus, PageTransitionLayerContextValue> = {
current: { status: 'current', isCurrentLayer: true },
stacked: { status: 'stacked', isCurrentLayer: false },
exiting: { status: 'exiting', isCurrentLayer: false },
};
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
@@ -106,17 +106,16 @@
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
:global([data-theme='dark']) {
.floatingActionSurface {
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
}
@@ -52,7 +52,7 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
const titleTooltip = typeof title === 'string' ? title : undefined;
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const shouldRenderFloatingAction = Boolean(floatingAction) && isCurrentLayer;
const floatingActionRef = useRef<HTMLDivElement | null>(null);
@@ -0,0 +1,63 @@
import { useMemo, type Ref } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
type ConfigSourceEditorProps = {
value: string;
onChange: (value: string) => void;
editorRef?: Ref<ReactCodeMirrorRef>;
theme: 'light' | 'dark';
editable: boolean;
placeholder: string;
};
export default function ConfigSourceEditor({
value,
onChange,
editorRef,
theme,
editable,
placeholder,
}: ConfigSourceEditorProps) {
const extensions = useMemo(
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
[]
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={extensions}
theme={theme}
editable={editable}
placeholder={placeholder}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true,
}}
/>
);
}
@@ -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,6 +284,90 @@
@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 {
@@ -256,8 +395,9 @@
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);
--glass-blur: 14px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
-ms-overflow-style: none;
scrollbar-width: none;
@@ -269,10 +409,9 @@
.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));
left: 0;
top: 0;
will-change: transform, width, max-height;
z-index: 45;
opacity: 0;
pointer-events: none;
@@ -290,9 +429,7 @@
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);
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
-ms-overflow-style: none;
scrollbar-width: none;
@@ -447,6 +584,10 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
@include mobile {
grid-template-columns: minmax(0, 1fr);
}
}
.sectionStack {
@@ -729,9 +870,19 @@
}
@include mobile {
.overview {
gap: 14px;
padding: 18px;
border-radius: 24px;
}
.overviewFocusLink {
padding: 12px 14px;
}
.sections {
border-radius: 26px;
padding: 18px;
padding: 16px;
}
.subsection {
@@ -739,7 +890,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;
}
}
+87 -19
View File
@@ -179,7 +179,7 @@ export function VisualConfigEditor({
}: VisualConfigEditorProps) {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const isMobile = useMediaQuery('(max-width: 768px)');
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
@@ -196,6 +196,10 @@ export function VisualConfigEditor({
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';
@@ -330,6 +334,7 @@ export function VisualConfigEditor({
);
useEffect(() => {
if (!isCurrentLayer) return undefined;
if (typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
@@ -353,7 +358,28 @@ export function VisualConfigEditor({
}
return () => observer.disconnect();
}, [sections]);
}, [isCurrentLayer, sections]);
useEffect(() => {
if (!isCurrentLayer || !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, isCurrentLayer, isMobile]);
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
setActiveSectionId(sectionId);
@@ -367,10 +393,9 @@ export function VisualConfigEditor({
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('transform');
floatingElement.style.removeProperty('width');
floatingElement.style.removeProperty('max-height');
floatingElement.style.removeProperty('opacity');
floatingElement.style.removeProperty('pointer-events');
};
@@ -380,7 +405,8 @@ export function VisualConfigEditor({
return undefined;
}
const getHeaderHeight = () => {
/* ---- Cache header height recomputed only on resize ---- */
const computeHeaderHeight = () => {
const header = document.querySelector('.main-header') as HTMLElement | null;
if (header) return header.getBoundingClientRect().height;
@@ -388,8 +414,14 @@ export function VisualConfigEditor({
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : 64;
};
let headerHeight = computeHeaderHeight();
/* ---- Cache content scroller resolved once ---- */
const contentScroller = document.querySelector('.content') as HTMLElement | null;
/* ---- Cache floating height from previous frame ---- */
let cachedFloatingHeight = floatingElement.getBoundingClientRect().height || 200;
const getContentScroller = () => document.querySelector('.content') as HTMLElement | null;
let frameId = 0;
const updateFloatingPosition = () => {
@@ -397,10 +429,9 @@ export function VisualConfigEditor({
const anchorRect = anchorElement.getBoundingClientRect();
const workspaceRect = workspaceElement.getBoundingClientRect();
const floatingHeight = floatingElement.getBoundingClientRect().height;
const stickyTop = getHeaderHeight() + 20;
const stickyTop = headerHeight + 20;
const viewportPadding = 16;
const maxTop = workspaceRect.bottom - floatingHeight;
const maxTop = workspaceRect.bottom - cachedFloatingHeight;
const unclampedTop = Math.min(Math.max(anchorRect.top, stickyTop), maxTop);
const top = Math.max(unclampedTop, viewportPadding);
const left = Math.max(anchorRect.left, viewportPadding);
@@ -411,10 +442,9 @@ export function VisualConfigEditor({
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.transform = `translate3d(${left}px, ${top}px, 0)`;
floatingElement.style.width = `${width}px`;
floatingElement.style.maxHeight = `${maxHeight}px`;
floatingElement.style.opacity = isVisible ? '1' : '0';
floatingElement.style.pointerEvents = isVisible ? 'auto' : 'none';
};
@@ -424,10 +454,15 @@ export function VisualConfigEditor({
frameId = requestAnimationFrame(updateFloatingPosition);
};
const handleResize = () => {
headerHeight = computeHeaderHeight();
cachedFloatingHeight = floatingElement.getBoundingClientRect().height || cachedFloatingHeight;
requestPositionUpdate();
};
requestPositionUpdate();
const contentScroller = getContentScroller();
window.addEventListener('resize', requestPositionUpdate);
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', requestPositionUpdate, { passive: true });
contentScroller?.addEventListener('scroll', requestPositionUpdate, { passive: true });
@@ -435,12 +470,11 @@ export function VisualConfigEditor({
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('resize', handleResize);
window.removeEventListener('scroll', requestPositionUpdate);
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
clearFloatingStyles();
@@ -532,6 +566,40 @@ export function VisualConfigEditor({
</div>
<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>
) : null}
</button>
))}
</div>
</div>
) : null}
<aside ref={sidebarAnchorRef} className={styles.sidebar}>
{isFloatingSidebar ? (
<div className={styles.sidebarPlaceholder} aria-hidden="true" />
@@ -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
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface ClaudeSectionProps {
export function ClaudeSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function ClaudeSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface CodexSectionProps {
export function CodexSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function CodexSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface GeminiSectionProps {
export function GeminiSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function GeminiSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -11,8 +11,8 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -21,7 +21,7 @@ import { getOpenAIProviderStats, getStatsBySource } from '../utils';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -34,7 +34,7 @@ interface OpenAISectionProps {
export function OpenAISection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -57,13 +57,13 @@ export function OpenAISection({
});
const filteredDetails = sourceIds.size
? usageDetails.filter((detail) => sourceIds.has(detail.source))
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -17,10 +17,11 @@
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow-x: auto;
@@ -104,8 +105,6 @@
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface VertexSectionProps {
export function VertexSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function VertexSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -1,11 +1,22 @@
import { useCallback } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export const useProviderStats = () => {
const keyStats = useUsageStatsStore((state) => state.keyStats);
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
const isLoading = useUsageStatsStore((state) => state.loading);
const EMPTY_KEY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
export type UseProviderStatsOptions = {
enabled?: boolean;
};
export const useProviderStats = (options: UseProviderStatsOptions = {}) => {
const enabled = options.enabled ?? true;
const keyStats = useUsageStatsStore((state) => (enabled ? state.keyStats : EMPTY_KEY_STATS));
const usageDetails = useUsageStatsStore((state) =>
enabled ? state.usageDetails : EMPTY_USAGE_DETAILS
);
const isLoading = useUsageStatsStore((state) => (enabled ? state.loading : false));
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
// 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。
@@ -20,7 +31,7 @@ export const useProviderStats = () => {
useInterval(() => {
void refreshKeyStats().catch(() => {});
}, 240_000);
}, enabled ? 240_000 : null);
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
};
+17 -2
View File
@@ -64,6 +64,8 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
cardIdleMessageKey?: string;
cardClassName: string;
defaultType: string;
canRefresh?: boolean;
onRefresh?: () => void;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
@@ -75,6 +77,8 @@ export function QuotaCard<TState extends QuotaStatusState>({
cardIdleMessageKey,
cardClassName,
defaultType,
canRefresh = false,
onRefresh,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
@@ -90,7 +94,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
@@ -120,7 +124,18 @@ export function QuotaCard<TState extends QuotaStatusState>({
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
onRefresh ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={onRefresh}
disabled={!canRefresh}
>
{t(idleMessageKey)}
</button>
) : (
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
)
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
+50 -5
View File
@@ -8,8 +8,9 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import { useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { getStatusFromError } from '@/utils/quota';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
@@ -105,6 +106,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const showNotification = useNotificationStore((state) => state.showNotification);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
@@ -202,6 +204,39 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
});
}, [filteredFiles, loading, setQuota]);
const refreshQuotaForFile = useCallback(
async (file: AuthFileItem) => {
if (disabled || file.disabled) return;
if (quota[file.name]?.status === 'loading') return;
setQuota((prev) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
setQuota((prev) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
setQuota((prev) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(
t('auth_files.quota_refresh_failed', { name: file.name, message }),
'error'
);
}
},
[config, disabled, quota, setQuota, showNotification, t]
);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
@@ -222,15 +257,21 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
variant="secondary"
size="sm"
className={`${styles.viewModeButton} ${
effectiveViewMode === 'paged' ? styles.viewModeButtonActive : ''
}`}
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
variant="secondary"
size="sm"
className={`${styles.viewModeButton} ${
effectiveViewMode === 'all' ? styles.viewModeButtonActive : ''
}`}
onClick={() => {
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
setShowTooManyWarning(true);
@@ -245,13 +286,15 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
<Button
variant="secondary"
size="sm"
className={styles.refreshAllButton}
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
title={t('quota_management.refresh_all_credentials')}
aria-label={t('quota_management.refresh_all_credentials')}
>
{!isRefreshing && <IconRefreshCw size={16} />}
{t('quota_management.refresh_all_credentials')}
</Button>
</div>
}
@@ -274,6 +317,8 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
cardIdleMessageKey={config.cardIdleMessageKey}
cardClassName={config.cardClassName}
defaultType={config.type}
canRefresh={!disabled && !item.disabled}
onRefresh={() => void refreshQuotaForFile(item)}
renderQuotaItems={config.renderQuotaItems}
/>
))}
+27 -5
View File
@@ -84,6 +84,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
const geminiCliSupplementaryRequestIds = new Map<string, number>();
const geminiCliSupplementaryCache = new Map<
string,
@@ -721,7 +723,11 @@ const renderAntigravityItems = (
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
});
};
@@ -795,7 +801,11 @@ const renderCodexItems = (
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -886,7 +896,11 @@ const renderGeminiCliItems = (
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -1078,7 +1092,11 @@ const renderClaudeItems = (
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -1293,7 +1311,11 @@ const renderKimiItems = (
: null
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
});
};
+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}
+153 -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,78 @@ 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 | null): ActiveTooltipState | null => {
if (!anchorEl || !anchorEl.isConnected) {
return null;
}
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 +161,48 @@ 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();
const anchorEl = e.currentTarget;
setActiveTooltip((prev) => (prev?.idx === idx ? null : buildTooltipState(idx, anchorEl)));
}
},
[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 +210,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
)}
</div>
);
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
};
const rateClass = !hasData
@@ -138,32 +234,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>
+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 {
@@ -8,17 +8,32 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
const usageDetailsByAuthIndex = new Map<string, UsageDetail[]>();
usageDetails.forEach((detail) => {
const authIndexKey = normalizeAuthIndex(detail.auth_index);
if (!authIndexKey) return;
const list = usageDetailsByAuthIndex.get(authIndexKey);
if (list) {
list.push(detail);
} else {
usageDetailsByAuthIndex.set(authIndexKey, [detail]);
}
});
const uniqueAuthIndexKeys = new Set<string>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
if (!authIndexKey) return;
uniqueAuthIndexKeys.add(authIndexKey);
});
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndex(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
uniqueAuthIndexKeys.forEach((authIndexKey) => {
cache.set(
authIndexKey,
calculateStatusBarData(usageDetailsByAuthIndex.get(authIndexKey) ?? [])
);
});
return cache;
+20 -5
View File
@@ -21,13 +21,23 @@ const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODE
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
typeof value === 'string' && AUTH_FILES_SORT_MODE_SET.has(value as AuthFilesSortMode);
const readAuthFilesUiStateFromStorage = (
storage: Pick<Storage, 'getItem'> | null | undefined
): AuthFilesUiState | null => {
if (!storage) return null;
const raw = storage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
};
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
return (
readAuthFilesUiStateFromStorage(window.localStorage) ??
readAuthFilesUiStateFromStorage(window.sessionStorage)
);
} catch {
return null;
}
@@ -36,7 +46,12 @@ export const readAuthFilesUiState = (): AuthFilesUiState | null => {
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
window.localStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
try {
window.sessionStorage.removeItem(AUTH_FILES_UI_STATE_KEY);
} catch {
// ignore
}
+14 -4
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
export type HeaderRefreshHandler = () => void | Promise<void>;
@@ -9,9 +9,19 @@ export const triggerHeaderRefresh = async () => {
await activeHeaderRefreshHandler();
};
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null, enabled = true) => {
const lastHandlerRef = useRef<HeaderRefreshHandler | null>(null);
useEffect(() => {
if (!handler) return;
const previousHandler = lastHandlerRef.current;
lastHandlerRef.current = handler ?? null;
if (!enabled || !handler) {
if (previousHandler && activeHeaderRefreshHandler === previousHandler) {
activeHeaderRefreshHandler = null;
}
return;
}
activeHeaderRefreshHandler = handler;
@@ -20,5 +30,5 @@ export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
activeHeaderRefreshHandler = null;
}
};
}, [handler]);
}, [enabled, handler]);
};
+346 -43
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
PayloadFilterRule,
@@ -189,7 +189,9 @@ export function getPayloadParamValidationError(
}
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param))));
return rules.some((rule) =>
rule.params.some((param) => Boolean(getPayloadParamValidationError(param)))
);
}
function deepClone<T>(value: T): T {
@@ -197,6 +199,72 @@ function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function arePayloadModelEntriesEqual(
left: PayloadRule['models'],
right: PayloadRule['models']
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id || a.name !== b.name || a.protocol !== b.protocol) return false;
}
return true;
}
function arePayloadParamEntriesEqual(
left: PayloadRule['params'],
right: PayloadRule['params']
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id || a.path !== b.path || a.valueType !== b.valueType || a.value !== b.value) {
return false;
}
}
return true;
}
function arePayloadRulesEqual(left: PayloadRule[], right: PayloadRule[]): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id) return false;
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
if (!arePayloadParamEntriesEqual(a.params, b.params)) return false;
}
return true;
}
function arePayloadFilterRulesEqual(
left: PayloadFilterRule[],
right: PayloadFilterRule[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id) return false;
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
if (a.params.length !== b.params.length) return false;
for (let j = 0; j < a.params.length; j += 1) {
if (a.params[j] !== b.params[j]) return false;
}
}
return true;
}
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
if (typeof raw === 'number') {
return { valueType: 'number', value: String(raw) };
@@ -426,15 +494,266 @@ function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array<Record<str
.filter((rule) => rule.models.length > 0);
}
export function useVisualConfig() {
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
type VisualConfigState = {
visualValues: VisualConfigValues;
baselineValues: VisualConfigValues;
dirtyFields: Set<string>;
visualParseError: string | null;
};
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const [visualParseError, setVisualParseError] = useState<string | null>(null);
type VisualConfigAction =
| {
type: 'load_success';
values: VisualConfigValues;
}
| {
type: 'load_error';
error: string;
}
| {
type: 'set_values';
values: Partial<VisualConfigValues>;
};
function createInitialVisualConfigState(): VisualConfigState {
const initialValues = deepClone(DEFAULT_VISUAL_VALUES);
return {
visualValues: initialValues,
baselineValues: deepClone(initialValues),
dirtyFields: new Set(),
visualParseError: null,
};
}
function mergeVisualConfigValues(
currentValues: VisualConfigValues,
patch: Partial<VisualConfigValues>
): VisualConfigValues {
const nextValues: VisualConfigValues = { ...currentValues, ...patch } as VisualConfigValues;
if (patch.streaming) {
nextValues.streaming = { ...currentValues.streaming, ...patch.streaming };
}
return nextValues;
}
function getNextDirtyFields(
currentDirtyFields: Set<string>,
patch: Partial<VisualConfigValues>,
nextValues: VisualConfigValues,
baselineValues: VisualConfigValues
): Set<string> {
const nextDirtyFields = new Set(currentDirtyFields);
const updateDirty = (key: string, isEqual: boolean) => {
if (isEqual) {
nextDirtyFields.delete(key);
} else {
nextDirtyFields.add(key);
}
};
if (Object.prototype.hasOwnProperty.call(patch, 'host')) {
updateDirty('host', nextValues.host === baselineValues.host);
}
if (Object.prototype.hasOwnProperty.call(patch, 'port')) {
updateDirty('port', nextValues.port === baselineValues.port);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsEnable')) {
updateDirty('tlsEnable', nextValues.tlsEnable === baselineValues.tlsEnable);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsCert')) {
updateDirty('tlsCert', nextValues.tlsCert === baselineValues.tlsCert);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsKey')) {
updateDirty('tlsKey', nextValues.tlsKey === baselineValues.tlsKey);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmAllowRemote')) {
updateDirty('rmAllowRemote', nextValues.rmAllowRemote === baselineValues.rmAllowRemote);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmSecretKey')) {
updateDirty('rmSecretKey', nextValues.rmSecretKey === baselineValues.rmSecretKey);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmDisableControlPanel')) {
updateDirty(
'rmDisableControlPanel',
nextValues.rmDisableControlPanel === baselineValues.rmDisableControlPanel
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmPanelRepo')) {
updateDirty('rmPanelRepo', nextValues.rmPanelRepo === baselineValues.rmPanelRepo);
}
if (Object.prototype.hasOwnProperty.call(patch, 'authDir')) {
updateDirty('authDir', nextValues.authDir === baselineValues.authDir);
}
if (Object.prototype.hasOwnProperty.call(patch, 'apiKeysText')) {
updateDirty('apiKeysText', nextValues.apiKeysText === baselineValues.apiKeysText);
}
if (Object.prototype.hasOwnProperty.call(patch, 'debug')) {
updateDirty('debug', nextValues.debug === baselineValues.debug);
}
if (Object.prototype.hasOwnProperty.call(patch, 'commercialMode')) {
updateDirty('commercialMode', nextValues.commercialMode === baselineValues.commercialMode);
}
if (Object.prototype.hasOwnProperty.call(patch, 'loggingToFile')) {
updateDirty('loggingToFile', nextValues.loggingToFile === baselineValues.loggingToFile);
}
if (Object.prototype.hasOwnProperty.call(patch, 'logsMaxTotalSizeMb')) {
updateDirty(
'logsMaxTotalSizeMb',
nextValues.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'usageStatisticsEnabled')) {
updateDirty(
'usageStatisticsEnabled',
nextValues.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'proxyUrl')) {
updateDirty('proxyUrl', nextValues.proxyUrl === baselineValues.proxyUrl);
}
if (Object.prototype.hasOwnProperty.call(patch, 'forceModelPrefix')) {
updateDirty(
'forceModelPrefix',
nextValues.forceModelPrefix === baselineValues.forceModelPrefix
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'requestRetry')) {
updateDirty('requestRetry', nextValues.requestRetry === baselineValues.requestRetry);
}
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryCredentials')) {
updateDirty(
'maxRetryCredentials',
nextValues.maxRetryCredentials === baselineValues.maxRetryCredentials
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryInterval')) {
updateDirty(
'maxRetryInterval',
nextValues.maxRetryInterval === baselineValues.maxRetryInterval
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'wsAuth')) {
updateDirty('wsAuth', nextValues.wsAuth === baselineValues.wsAuth);
}
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchProject')) {
updateDirty(
'quotaSwitchProject',
nextValues.quotaSwitchProject === baselineValues.quotaSwitchProject
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchPreviewModel')) {
updateDirty(
'quotaSwitchPreviewModel',
nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
updateDirty(
'payloadDefaultRules',
arePayloadRulesEqual(nextValues.payloadDefaultRules, baselineValues.payloadDefaultRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRawRules')) {
updateDirty(
'payloadDefaultRawRules',
arePayloadRulesEqual(nextValues.payloadDefaultRawRules, baselineValues.payloadDefaultRawRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRules')) {
updateDirty(
'payloadOverrideRules',
arePayloadRulesEqual(nextValues.payloadOverrideRules, baselineValues.payloadOverrideRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRawRules')) {
updateDirty(
'payloadOverrideRawRules',
arePayloadRulesEqual(
nextValues.payloadOverrideRawRules,
baselineValues.payloadOverrideRawRules
)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadFilterRules')) {
updateDirty(
'payloadFilterRules',
arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules)
);
}
if (patch.streaming) {
const streamingPatch = patch.streaming;
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) {
updateDirty(
'streaming.keepaliveSeconds',
nextValues.streaming.keepaliveSeconds === baselineValues.streaming.keepaliveSeconds
);
}
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'bootstrapRetries')) {
updateDirty(
'streaming.bootstrapRetries',
nextValues.streaming.bootstrapRetries === baselineValues.streaming.bootstrapRetries
);
}
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'nonstreamKeepaliveInterval')) {
updateDirty(
'streaming.nonstreamKeepaliveInterval',
nextValues.streaming.nonstreamKeepaliveInterval ===
baselineValues.streaming.nonstreamKeepaliveInterval
);
}
}
return nextDirtyFields;
}
function visualConfigReducer(
state: VisualConfigState,
action: VisualConfigAction
): VisualConfigState {
switch (action.type) {
case 'load_success':
return {
visualValues: action.values,
baselineValues: deepClone(action.values),
dirtyFields: new Set(),
visualParseError: null,
};
case 'load_error':
return {
...state,
visualParseError: action.error,
};
case 'set_values': {
const nextValues = mergeVisualConfigValues(state.visualValues, action.values);
const nextDirtyFields = getNextDirtyFields(
state.dirtyFields,
action.values,
nextValues,
state.baselineValues
);
return {
...state,
visualValues: nextValues,
dirtyFields: nextDirtyFields,
};
}
default:
return state;
}
}
export function useVisualConfig() {
const [state, dispatch] = useReducer(
visualConfigReducer,
undefined,
createInitialVisualConfigState
);
const { visualValues, visualParseError } = state;
const visualDirty = state.dirtyFields.size > 0;
const visualValidationErrors = useMemo(
() => getVisualConfigValidationErrors(visualValues),
[visualValues]
@@ -453,10 +772,6 @@ export function useVisualConfig() {
]
);
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
}, [baselineValues, visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const document = parseDocument(yamlContent);
@@ -483,14 +798,16 @@ export function useVisualConfig() {
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey:
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
typeof remoteManagement?.['secret-key'] === 'string'
? remoteManagement['secret-key']
: '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmPanelRepo:
typeof remoteManagement?.['panel-github-repository'] === 'string'
? remoteManagement['panel-github-repository']
: typeof remoteManagement?.['panel-repo'] === 'string'
? remoteManagement['panel-repo']
: '',
: '',
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: resolveApiKeysText(parsed),
@@ -509,12 +826,9 @@ export function useVisualConfig() {
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
quotaExceeded?.['switch-preview-model'] ?? true
),
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
routingStrategy:
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
@@ -529,13 +843,11 @@ export function useVisualConfig() {
},
};
setVisualValuesState(newValues);
setBaselineValues(deepClone(newValues));
setVisualParseError(null);
dispatch({ type: 'load_success', values: newValues });
return { ok: true as const };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Invalid YAML';
setVisualParseError(message);
dispatch({ type: 'load_error', error: message });
return { ok: false as const, error: message };
}
}, []);
@@ -621,10 +933,7 @@ export function useVisualConfig() {
) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
@@ -635,9 +944,13 @@ export function useVisualConfig() {
}
const keepaliveSeconds =
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
typeof values.streaming?.keepaliveSeconds === 'string'
? values.streaming.keepaliveSeconds
: '';
const bootstrapRetries =
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
typeof values.streaming?.bootstrapRetries === 'string'
? values.streaming.bootstrapRetries
: '';
const nonstreamKeepaliveInterval =
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
? values.streaming.nonstreamKeepaliveInterval
@@ -652,11 +965,7 @@ export function useVisualConfig() {
deleteIfMapEmpty(doc, ['streaming']);
}
setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
setIntFromStringInDoc(doc, ['nonstream-keepalive-interval'], nonstreamKeepaliveInterval);
if (
docHas(doc, ['payload']) ||
@@ -719,13 +1028,7 @@ export function useVisualConfig() {
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
setVisualValuesState((prev) => {
const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues;
if (newValues.streaming) {
next.streaming = { ...prev.streaming, ...newValues.streaming };
}
return next;
});
dispatch({ type: 'set_values', values: newValues });
}, []);
return {
+24 -3
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",
@@ -138,7 +140,17 @@
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
"available_models": "Available Models",
"available_models_desc": "Total models from all providers"
"available_models_desc": "Total models from all providers",
"welcome_back": "Welcome Back",
"greeting_morning": "Good Morning",
"greeting_afternoon": "Good Afternoon",
"greeting_evening": "Good Evening",
"greeting_night": "Good Night",
"caring_morning": "A fresh start — let's make today count.",
"caring_afternoon": "Steady progress — you're doing great.",
"caring_evening": "Wrapping up nicely — almost there.",
"caring_night": "Burning the midnight oil? Don't forget to rest.",
"system_overview": "System Overview"
},
"basic_settings": {
"title": "Basic Settings",
@@ -495,7 +507,7 @@
"pagination_next": "Next",
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"display_options_label": "Display options",
@@ -1133,16 +1145,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}}",
@@ -1333,7 +1353,8 @@
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota",
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
"refresh_all_credentials": "Refresh all credentials",
"card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data."
},
"system_info": {
"title": "Management Center Info",
+24 -3
View File
@@ -41,6 +41,8 @@
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
"copy": "Копировать",
"expand": "Развернуть",
"collapse": "Свернуть",
"status": "Статус",
"action": "Действие",
"custom_headers_label": "Пользовательские заголовки",
@@ -138,7 +140,17 @@
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
"available_models": "Доступные модели",
"available_models_desc": "Всего моделей от всех провайдеров"
"available_models_desc": "Всего моделей от всех провайдеров",
"welcome_back": "С возвращением",
"greeting_morning": "Доброе утро",
"greeting_afternoon": "Добрый день",
"greeting_evening": "Добрый вечер",
"greeting_night": "Доброй ночи",
"caring_morning": "Новый день — начнём продуктивно.",
"caring_afternoon": "Уверенный прогресс — отличная работа.",
"caring_evening": "День подходит к концу — финальный рывок.",
"caring_night": "Поздняя работа? Не забудьте отдохнуть.",
"system_overview": "Обзор системы"
},
"basic_settings": {
"title": "Основные настройки",
@@ -495,7 +507,7 @@
"pagination_next": "Следующая",
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
"search_label": "Поиск конфигов",
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"display_options_label": "Параметры отображения",
@@ -1136,16 +1148,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}}",
@@ -1338,7 +1358,8 @@
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
"refresh_files": "Обновить файлы авторизации",
"refresh_files_and_quota": "Обновить файлы и квоты",
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
"refresh_all_credentials": "Обновить все учётные данные",
"card_idle_hint": "Используйте кнопку «Обновить все учётные данные» сверху, чтобы загрузить актуальные данные по квотам."
},
"system_info": {
"title": "Информация о центре управления",
+24 -3
View File
@@ -41,6 +41,8 @@
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"expand": "展开",
"collapse": "收起",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头",
@@ -138,7 +140,17 @@
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
"available_models": "可用模型",
"available_models_desc": "所有提供商的模型总数"
"available_models_desc": "所有提供商的模型总数",
"welcome_back": "欢迎回来",
"greeting_morning": "早上好",
"greeting_afternoon": "下午好",
"greeting_evening": "晚上好",
"greeting_night": "夜深了",
"caring_morning": "新的一天,准备大展身手吧。",
"caring_afternoon": "稳步推进中,继续加油。",
"caring_evening": "今天辛苦了,收尾工作做好哦。",
"caring_night": "夜深了,别忘了早些休息。",
"system_overview": "系统概览"
},
"basic_settings": {
"title": "基础设置",
@@ -495,7 +507,7 @@
"pagination_next": "下一页",
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"display_options_label": "显示选项",
@@ -1133,16 +1145,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}}",
@@ -1333,7 +1353,8 @@
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度",
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
"refresh_all_credentials": "刷新全部凭证",
"card_idle_hint": "请使用顶部“刷新全部凭证”按钮获取最新额度。"
},
"system_info": {
"title": "管理中心信息",
+2
View File
@@ -5,6 +5,8 @@ import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import App from './App.tsx';
document.title = 'CLI Proxy API Management Center';
document.documentElement.setAttribute('translate', 'no');
document.documentElement.classList.add('notranslate');
const faviconEl = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (faviconEl) {
+72 -21
View File
@@ -13,6 +13,7 @@ import { ampcodeApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { areStringArraysEqual } from '@/utils/compare';
import {
buildAmpcodeFormState,
entriesToAmpcodeMappings,
@@ -38,20 +39,52 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>
return acc;
}, []);
const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) =>
entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({
upstreamApiKey: entry.upstreamApiKey,
apiKeys: entry.apiKeys,
}));
type AmpcodeFormBaseline = {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
upstreamApiKeys: ReturnType<typeof entriesToAmpcodeUpstreamApiKeys>;
modelMappings: ReturnType<typeof normalizeMappingEntries>;
};
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
JSON.stringify({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: normalizeUpstreamApiKeyEntries(form),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const buildAmpcodeBaseline = (form: AmpcodeFormState): AmpcodeFormBaseline => ({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const areUpstreamApiKeysEqual = (
a: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[],
b: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.upstreamApiKey !== right.upstreamApiKey) return false;
if (!areStringArraysEqual(left.apiKeys, right.apiKeys)) return false;
}
return true;
};
const areModelMappingsEqual = (
a: readonly { from: string; to: string }[],
b: readonly { from: string; to: string }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.from !== right.from || left.to !== right.to) return false;
}
return true;
};
export function AiProvidersAmpcodeEditPage() {
const { t } = useTranslation();
@@ -72,9 +105,7 @@ export function AiProvidersAmpcodeEditPage() {
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [baselineSignature, setBaselineSignature] = useState(() =>
buildAmpcodeSignature(buildAmpcodeFormState(null))
);
const [baseline, setBaseline] = useState(() => buildAmpcodeBaseline(buildAmpcodeFormState(null)));
const initializedRef = useRef(false);
const mountedRef = useRef(false);
@@ -119,7 +150,7 @@ export function AiProvidersAmpcodeEditPage() {
setError('');
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
setForm(initialForm);
setBaselineSignature(buildAmpcodeSignature(initialForm));
setBaseline(buildAmpcodeBaseline(initialForm));
void (async () => {
try {
@@ -131,7 +162,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
const nextForm = buildAmpcodeFormState(ampcode);
setForm(nextForm);
setBaselineSignature(buildAmpcodeSignature(nextForm));
setBaseline(buildAmpcodeBaseline(nextForm));
} catch (err: unknown) {
if (!mountedRef.current) return;
setError(getErrorMessage(err) || t('notification.refresh_failed'));
@@ -143,8 +174,28 @@ export function AiProvidersAmpcodeEditPage() {
})();
}, [clearCache, t, updateConfigValue]);
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedUpstreamApiKeys = useMemo(
() => entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
[form.upstreamApiKeyEntries]
);
const normalizedModelMappings = useMemo(
() => normalizeMappingEntries(form.mappingEntries),
[form.mappingEntries]
);
const isUpstreamApiKeysDirty = useMemo(
() => !areUpstreamApiKeysEqual(baseline.upstreamApiKeys, normalizedUpstreamApiKeys),
[baseline.upstreamApiKeys, normalizedUpstreamApiKeys]
);
const isModelMappingsDirtyNormalized = useMemo(
() => !areModelMappingsEqual(baseline.modelMappings, normalizedModelMappings),
[baseline.modelMappings, normalizedModelMappings]
);
const isDirty =
baseline.upstreamUrl !== form.upstreamUrl.trim() ||
baseline.upstreamApiKey !== form.upstreamApiKey.trim() ||
baseline.forceModelMappings !== Boolean(form.forceModelMappings) ||
isUpstreamApiKeysDirty ||
isModelMappingsDirtyNormalized;
const canGuard = !loading && !saving;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -263,7 +314,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
allowNextNavigation();
setBaselineSignature(buildAmpcodeSignature(form));
setBaseline(buildAmpcodeBaseline(form));
handleBack();
} catch (err: unknown) {
const message = getErrorMessage(err);
+74 -22
View File
@@ -9,8 +9,10 @@ import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ClaudeEditBaseline } from '@/stores/useClaudeEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -89,19 +91,28 @@ const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
};
};
const buildClaudeSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
const buildClaudeBaseline = (form: ProviderFormState): ClaudeEditBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
const areCloakConfigsEqual = (left: ClaudeEditBaseline['cloak'], right: ClaudeEditBaseline['cloak']) => {
if (left === right) return true;
if (!left || !right) return false;
if (left.mode !== right.mode || left.strictMode !== right.strictMode) return false;
if (left.sensitiveWords === null || right.sensitiveWords === null) {
return left.sensitiveWords === right.sensitiveWords;
}
return areStringArraysEqual(left.sensitiveWords, right.sensitiveWords);
};
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
@@ -137,7 +148,7 @@ export function AiProvidersClaudeEditLayout() {
const acquireDraft = useClaudeEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useClaudeEditDraftStore((state) => state.releaseDraft);
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
const setDraftBaselineSignature = useClaudeEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftBaseline = useClaudeEditDraftStore((state) => state.setDraftBaseline);
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
@@ -241,9 +252,9 @@ export function AiProvidersClaudeEditLayout() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const baselineSignature = buildClaudeSignature(seededForm);
const baseline = buildClaudeBaseline(seededForm);
initDraft(draftKey, {
baselineSignature,
baseline,
form: seededForm,
testModel: available[0] || '',
testStatus: 'idle',
@@ -254,7 +265,7 @@ export function AiProvidersClaudeEditLayout() {
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
baselineSignature: buildClaudeSignature(emptyForm),
baseline: buildClaudeBaseline(emptyForm),
form: emptyForm,
testModel: '',
testStatus: 'idle',
@@ -263,9 +274,50 @@ export function AiProvidersClaudeEditLayout() {
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const baseline = draft?.baseline ?? null;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeClaudeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedCloak = useMemo(() => normalizeCloakConfig(form.cloak), [form.cloak]);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(() => {
if (!baseline) return false;
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
}, [baseline, normalizedHeaders]);
const isModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areModelEntriesEqual(baseline.models, normalizedModels);
}, [baseline, normalizedModels]);
const isExcludedModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels);
}, [baseline, normalizedExcludedModels]);
const isCloakDirty = useMemo(() => {
if (!baseline) return false;
return !areCloakConfigsEqual(baseline.cloak, normalizedCloak);
}, [baseline, normalizedCloak]);
const isDirty =
Boolean(draft?.initialized) &&
baseline !== null &&
(baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty ||
isCloakDirty);
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/claude/${params.index ?? ''}`;
@@ -384,7 +436,7 @@ export function AiProvidersClaudeEditLayout() {
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildClaudeSignature(form));
setDraftBaseline(draftKey, buildClaudeBaseline(form));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -403,7 +455,7 @@ export function AiProvidersClaudeEditLayout() {
invalidIndex,
invalidIndexParam,
resolvedLoading,
setDraftBaselineSignature,
setDraftBaseline,
saving,
showNotification,
t,
+65 -23
View File
@@ -16,6 +16,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
@@ -63,21 +64,30 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type CodexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
websockets: boolean;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildCodexBaseline = (form: ProviderFormState): CodexFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersCodexEditPage() {
const { t } = useTranslation();
@@ -98,9 +108,7 @@ 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 [baseline, setBaseline] = useState(() => buildCodexBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -186,16 +194,50 @@ export function AiProvidersCodexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
}, [initialData, loading]);
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.websockets !== Boolean(form.websockets) ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -430,7 +472,7 @@ export function AiProvidersCodexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildCodexSignature(form));
setBaseline(buildCodexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+62 -22
View File
@@ -15,6 +15,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
@@ -60,20 +61,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type GeminiFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildGeminiBaseline = (form: GeminiFormState): GeminiFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersGeminiEditPage() {
const { t } = useTranslation();
@@ -94,9 +103,7 @@ 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 [baseline, setBaseline] = useState(() => buildGeminiBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -185,12 +192,12 @@ export function AiProvidersGeminiEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
@@ -378,8 +385,41 @@ export function AiProvidersGeminiEditPage() {
setModelDiscoveryOpen(false);
};
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -432,7 +472,7 @@ export function AiProvidersGeminiEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildGeminiSignature(form));
setBaseline(buildGeminiBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+74 -22
View File
@@ -9,9 +9,10 @@ import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputList
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual } from '@/utils/compare';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
import type { KeyTestStatus, OpenAIEditBaseline } from '@/stores/useOpenAIEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -103,18 +104,33 @@ const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
return acc;
}, []);
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
JSON.stringify({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
const buildOpenAIBaseline = (form: OpenAIFormState, testModel: string): OpenAIEditBaseline => ({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
const areNormalizedApiKeyEntriesEqual = (
a: OpenAIEditBaseline['apiKeyEntries'],
b: ReturnType<typeof normalizeApiKeyEntries>
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.apiKey !== right.apiKey || left.proxyUrl !== right.proxyUrl) return false;
if (!areKeyValueEntriesEqual(left.headers, right.headers)) return false;
}
return true;
};
export function AiProvidersOpenAIEditLayout() {
const { t } = useTranslation();
@@ -152,7 +168,7 @@ export function AiProvidersOpenAIEditLayout() {
const acquireDraft = useOpenAIEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useOpenAIEditDraftStore((state) => state.releaseDraft);
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
const setDraftBaselineSignature = useOpenAIEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftBaseline = useOpenAIEditDraftStore((state) => state.setDraftBaseline);
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
@@ -286,9 +302,9 @@ export function AiProvidersOpenAIEditLayout() {
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
const baselineSignature = buildOpenAISignature(seededForm, initialTestModel);
const baseline = buildOpenAIBaseline(seededForm, initialTestModel);
initDraft(draftKey, {
baselineSignature,
baseline,
form: seededForm,
testModel: initialTestModel,
testStatus: 'idle',
@@ -298,7 +314,7 @@ export function AiProvidersOpenAIEditLayout() {
} else {
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
baselineSignature: buildOpenAISignature(emptyForm, ''),
baseline: buildOpenAIBaseline(emptyForm, ''),
form: emptyForm,
testModel: '',
testStatus: 'idle',
@@ -362,9 +378,45 @@ export function AiProvidersOpenAIEditLayout() {
);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const baseline = draft?.baseline ?? null;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedApiKeyEntries = useMemo(
() => normalizeApiKeyEntries(form.apiKeyEntries),
[form.apiKeyEntries]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const normalizedTestModel = useMemo(() => String(testModel ?? '').trim(), [testModel]);
const isHeadersDirty = useMemo(() => {
if (!baseline) return false;
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
}, [baseline, normalizedHeaders]);
const isModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areModelEntriesEqual(baseline.models, normalizedModels);
}, [baseline, normalizedModels]);
const isApiKeyEntriesDirty = useMemo(() => {
if (!baseline) return false;
return !areNormalizedApiKeyEntriesEqual(baseline.apiKeyEntries, normalizedApiKeyEntries);
}, [baseline, normalizedApiKeyEntries]);
const isDirty =
Boolean(draft?.initialized) &&
baseline !== null &&
(baseline.name !== form.name.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== form.prefix.trim() ||
baseline.baseUrl !== form.baseUrl.trim() ||
baseline.testModel !== normalizedTestModel ||
isHeadersDirty ||
isApiKeyEntriesDirty ||
isModelsDirty);
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/openai/${params.index ?? ''}`;
@@ -445,7 +497,7 @@ export function AiProvidersOpenAIEditLayout() {
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildOpenAISignature(form, testModel));
setDraftBaseline(draftKey, buildOpenAIBaseline(form, testModel));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -460,7 +512,7 @@ export function AiProvidersOpenAIEditLayout() {
form,
handleBack,
providers,
setDraftBaselineSignature,
setDraftBaseline,
showNotification,
t,
testModel,
+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()}`;
}
+2 -2
View File
@@ -9,7 +9,7 @@ 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';
@@ -68,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,
+28 -13
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
@@ -15,10 +15,12 @@ import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -59,7 +61,16 @@ export function AiProvidersPage() {
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats({
enabled: isCurrentLayer,
});
const usageDetailsBySource = useMemo(
() => indexUsageDetailsBySource(usageDetails),
[usageDetails]
);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -113,8 +124,12 @@ export function AiProvidersPage() {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs();
}, [loadConfigs]);
useEffect(() => {
if (!isCurrentLayer) return;
void loadKeyStats().catch(() => {});
}, [loadConfigs, loadKeyStats]);
}, [isCurrentLayer, loadKeyStats]);
useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
@@ -130,7 +145,7 @@ export function AiProvidersPage() {
config?.openaiCompatibility,
]);
useHeaderRefresh(refreshKeyStats);
useHeaderRefresh(refreshKeyStats, isCurrentLayer);
const openEditor = useCallback(
(path: string) => {
@@ -149,7 +164,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteGeminiKey(entry.apiKey);
await providersApi.deleteGeminiKey(entry.apiKey, entry.baseUrl);
const next = geminiKeys.filter((_, idx) => idx !== index);
setGeminiKeys(next);
updateConfigValue('gemini-api-key', next);
@@ -282,14 +297,14 @@ export function AiProvidersPage() {
onConfirm: async () => {
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
await providersApi.deleteCodexConfig(entry.apiKey, entry.baseUrl);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
await providersApi.deleteClaudeConfig(entry.apiKey, entry.baseUrl);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
@@ -314,7 +329,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
await providersApi.deleteVertexConfig(entry.apiKey, entry.baseUrl);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
@@ -362,7 +377,7 @@ export function AiProvidersPage() {
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -377,7 +392,7 @@ export function AiProvidersPage() {
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -392,7 +407,7 @@ export function AiProvidersPage() {
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -407,7 +422,7 @@ export function AiProvidersPage() {
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -432,7 +447,7 @@ export function AiProvidersPage() {
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+62 -18
View File
@@ -15,6 +15,7 @@ import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -47,18 +48,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildVertexSignature = (form: VertexFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type VertexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildVertexBaseline = (form: VertexFormState): VertexFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersVertexEditPage() {
const { t } = useTranslation();
@@ -79,7 +90,7 @@ export function AiProvidersVertexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
const [baseline, setBaseline] = useState(() => buildVertexBaseline(buildEmptyForm()));
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -160,18 +171,51 @@ export function AiProvidersVertexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -230,7 +274,7 @@ export function AiProvidersVertexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildVertexSignature(form));
setBaseline(buildVertexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -68,6 +68,15 @@ const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
const escapeWildcardSearchSegment = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildWildcardSearch = (value: string): RegExp | null => {
if (!value.includes('*')) return null;
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
return new RegExp(pattern, 'i');
};
export function AuthFilesPage() {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
@@ -368,18 +377,25 @@ export function AuthFilesPage() {
return counts;
}, [filesMatchingProblemFilter]);
const normalizedSearch = search.trim();
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
const filtered = useMemo(() => {
const normalizedTerm = normalizedSearch.toLowerCase();
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);
!normalizedSearch ||
[item.name, item.type, item.provider].some((value) => {
const content = (value || '').toString();
return wildcardSearch
? wildcardSearch.test(content)
: content.toLowerCase().includes(normalizedTerm);
});
return matchType && matchSearch;
});
}, [filesMatchingProblemFilter, filter, search]);
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
const sorted = useMemo(() => {
const copy = [...filtered];
+34 -5
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 {
@@ -416,10 +422,11 @@
align-items: center;
gap: 8px;
padding: 8px 10px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
border-radius: 999px;
box-shadow: var(--shadow-lg);
max-width: inherit;
@@ -434,6 +441,7 @@
.floatingStatus {
display: inline-flex;
align-items: center;
min-width: 0;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
@@ -449,6 +457,12 @@
text-overflow: ellipsis;
}
.floatingStatusCompact {
max-width: 112px;
padding: 0 8px;
font-size: 10px;
}
.floatingActionButton {
@include button-reset;
position: relative;
@@ -503,7 +517,9 @@
}
.floatingStatus {
display: none;
max-width: min(180px, 40vw);
font-size: 10px;
padding: 0 8px;
}
.floatingActionButton {
@@ -512,3 +528,16 @@
flex: 0 0 auto;
}
}
@media (max-width: 480px) {
.floatingStatus {
max-width: min(132px, 38vw);
}
}
@media (max-width: 380px) {
.pageHeader,
.workspaceShell {
padding: 14px;
}
}
+59 -46
View File
@@ -1,10 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { parse as parseYaml, parseDocument } from 'yaml';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Button } from '@/components/ui/Button';
@@ -18,13 +15,16 @@ 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';
type ConfigEditorTab = 'visual' | 'source';
const LazyConfigSourceEditor = lazy(() => import('@/components/config/ConfigSourceEditor'));
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
@@ -38,11 +38,12 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
export function ConfigPage() {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : 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,
@@ -77,7 +78,7 @@ export function ConfigPage() {
total: 0,
});
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
const editorRef = useRef<ReactCodeMirrorRef>(null);
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
const floatingActionsRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected';
@@ -137,6 +138,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');
@@ -385,12 +404,6 @@ export function ConfigPage() {
};
}, [shouldRenderFloatingActions]);
// CodeMirror extensions
const extensions = useMemo(
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
[]
);
// Status text
const getStatusText = () => {
if (disableControls) return t('config_management.status_disconnected');
@@ -411,6 +424,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();
@@ -432,7 +460,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}
@@ -589,37 +623,16 @@ export function ConfigPage() {
</div>
<div className={styles.editorWrapper}>
<CodeMirror
ref={editorRef}
value={content}
onChange={handleChange}
extensions={extensions}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true,
}}
/>
<Suspense fallback={null}>
<LazyConfigSourceEditor
editorRef={editorRef}
value={content}
onChange={handleChange}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
/>
</Suspense>
</div>
</div>
)}
+366 -173
View File
@@ -1,59 +1,236 @@
@use 'sass:color';
@use '../styles/variables.scss' as *;
// Container
.dashboard {
display: flex;
flex-direction: column;
gap: $spacing-lg;
gap: $spacing-xl;
max-width: 1000px;
margin: 0 auto;
position: relative;
}
.header {
margin-bottom: $spacing-sm;
// Decorative Background Orbs
.backgroundOrbs {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.title {
font-size: 26px;
font-weight: 800;
.orb1 {
position: absolute;
width: 420px;
height: 420px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--primary-color) 8%, transparent),
transparent 70%
);
top: -140px;
right: -80px;
animation: orbFloat 22s ease-in-out infinite alternate;
}
.orb2 {
position: absolute;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--success-color) 6%, transparent),
transparent 70%
);
bottom: 18%;
left: -100px;
animation: orbFloat 28s ease-in-out infinite alternate-reverse;
}
@keyframes orbFloat {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(30px, -20px) scale(1.1);
}
}
// Hero Welcome Section
.hero {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: $spacing-lg;
padding: $spacing-2xl $spacing-xl;
border-radius: $radius-lg;
--glass-blur: 12px;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--bg-primary) 92%, transparent),
color-mix(in srgb, var(--bg-secondary) 80%, transparent)
);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
overflow: hidden;
animation: heroEnter 0.6s ease-out both;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
align-items: flex-start;
padding: $spacing-xl $spacing-lg;
}
}
// Large watermark text behind hero content
.heroWatermark {
position: absolute;
top: 50%;
left: $spacing-xl;
transform: translateY(-50%);
font-size: clamp(64px, 12vw, 120px);
font-weight: 900;
line-height: 1;
letter-spacing: -0.04em;
text-transform: uppercase;
color: var(--text-primary);
margin: 0;
line-height: 1.4;
opacity: 0.04;
white-space: nowrap;
pointer-events: none;
user-select: none;
animation: watermarkEnter 0.8s ease-out 0.1s both;
@media (max-width: $breakpoint-mobile) {
font-size: clamp(48px, 14vw, 80px);
left: $spacing-lg;
}
}
.subtitle {
@keyframes watermarkEnter {
from {
opacity: 0;
transform: translateY(-50%) translateX(-20px);
}
to {
opacity: 0.04;
transform: translateY(-50%) translateX(0);
}
}
.heroContent {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.heroGreeting {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary-color);
animation: fadeSlideUp 0.5s ease-out 0.1s both;
}
.heroTitle {
margin: 0;
font-size: clamp(32px, 5vw, 48px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
color: var(--text-primary);
animation: fadeSlideUp 0.5s ease-out 0.2s both;
}
.heroCaring {
margin: $spacing-xs 0 0;
font-size: 15px;
color: var(--text-secondary);
margin: $spacing-xs 0 0 0;
line-height: 1.5;
animation: fadeSlideUp 0.5s ease-out 0.3s both;
}
.connectionCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md $spacing-lg;
flex-wrap: wrap;
}
// Hero Meta (right side)
.connectionStatus {
.heroMeta {
position: relative;
z-index: 1;
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-end;
gap: $spacing-sm;
flex-shrink: 0;
animation: fadeSlideUp 0.5s ease-out 0.35s both;
@media (max-width: $breakpoint-mobile) {
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
gap: $spacing-sm;
}
}
.dateTimeBlock {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
@media (max-width: $breakpoint-mobile) {
align-items: flex-start;
}
}
.time {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.date {
font-size: 12px;
color: var(--text-secondary);
}
// Connection Pill
.connectionPill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: $radius-full;
--glass-blur: 8px;
background: var(--glass-bg-secondary);
border: 1px solid var(--glass-border);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
font-size: 12px;
}
.statusDot {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: $gray-400;
flex-shrink: 0;
&.connected {
background: $success-color;
box-shadow: 0 0 8px rgba($success-color, 0.5);
box-shadow: 0 0 6px rgba($success-color, 0.5);
}
&.connecting {
@@ -66,54 +243,41 @@
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.statusText {
.pillText {
font-weight: 600;
color: var(--text-primary);
}
.connectionInfo {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
}
.serverUrl {
font-family: $font-mono;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: $radius-md;
border: 1px solid var(--border-color);
}
.serverVersion {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
background: rgba($primary-color, 0.1);
padding: 4px 10px;
border-radius: $radius-full;
}
.buildDate {
font-size: 12px;
color: var(--text-secondary);
font-size: 11px;
color: var(--text-tertiary);
text-align: right;
@media (max-width: $breakpoint-mobile) {
text-align: left;
}
}
.statsGrid {
// Bento Stats Grid
.statsSection {
position: relative;
z-index: 1;
}
.sectionHeading {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin: 0 0 $spacing-md;
}
.bentoGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto auto;
gap: $spacing-md;
@media (max-width: 900px) {
@@ -125,150 +289,175 @@
}
}
.statCard {
.bentoCard {
display: flex;
align-items: center;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-lg;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
text-decoration: none;
transition: all $transition-fast;
transition:
border-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
animation: cardEnter 0.4s ease-out both;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-3px);
}
}
.statIcon {
// First card spans 2 rows the "hero stat"
.bentoLarge {
grid-row: 1 / 3;
justify-content: center;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--primary-color) 6%, var(--bg-primary)),
var(--bg-primary)
);
.bentoValue {
font-size: 44px;
}
.bentoIcon {
width: 56px;
height: 56px;
}
@media (max-width: 900px) {
grid-row: auto;
.bentoValue {
font-size: 32px;
}
.bentoIcon {
width: 44px;
height: 44px;
}
}
}
.bentoIcon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
width: 44px;
height: 44px;
border-radius: $radius-md;
background: var(--bg-secondary);
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-secondary));
color: var(--primary-color);
transition: background $transition-fast;
}
.statContent {
.bentoCard:hover .bentoIcon {
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
}
.bentoContent {
display: flex;
flex-direction: column;
gap: 2px;
}
.statValue {
font-size: 24px;
.bentoValue {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.statLabel {
.bentoLabel {
font-size: 13px;
color: var(--text-secondary);
}
.statSublabel {
.bentoSublabel {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.8;
opacity: 0.7;
margin-top: 2px;
}
.section {
// Config Pills Section
.configSection {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: $spacing-md;
animation: cardEnter 0.4s ease-out 0.5s both;
}
.sectionTitle {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.actionsGrid {
.configPillGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
a {
text-decoration: none;
}
}
.actionButton {
.configPill {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
// Button 内部的 span 需要 flex 对齐图标和文字
> span {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
}
svg {
flex-shrink: 0;
}
}
.configGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-sm;
}
.configItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-full;
font-size: 13px;
transition: border-color $transition-fast;
&:hover {
border-color: var(--border-hover);
}
}
.configPillWide {
flex-basis: 100%;
border-radius: $radius-md;
}
.configLabel {
font-size: 13px;
.configPillLabel {
color: var(--text-secondary);
white-space: nowrap;
}
.configValue {
font-size: 13px;
.configPillValue {
font-weight: 600;
color: var(--text-primary);
&.enabled {
&.on {
color: $success-color;
}
&.disabled {
&.off {
color: var(--text-secondary);
}
}
.configValueMono {
.configPillMono {
font-size: 12px;
font-family: $font-mono;
color: var(--text-secondary);
word-break: break-all;
}
// Config badges (routing strategy)
.configBadge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 12px;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
max-width: 100%;
@@ -294,50 +483,7 @@
background: var(--bg-primary);
}
.configItemFull {
grid-column: 1 / -1;
}
// Usage stats section
.usageGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: $spacing-sm;
}
.usageCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
text-align: center;
}
.usageValue {
font-size: 22px;
font-weight: 800;
color: var(--primary-color);
}
.usageLabel {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.usageLoading,
.usageEmpty {
padding: $spacing-lg;
text-align: center;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
// View More Link
.viewMoreLink {
display: inline-flex;
@@ -346,8 +492,55 @@
color: var(--primary-color);
text-decoration: none;
margin-top: $spacing-xs;
transition: color $transition-fast;
&:hover {
text-decoration: underline;
color: var(--primary-hover);
}
}
// Animations
@keyframes heroEnter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardEnter {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
+129 -68
View File
@@ -27,6 +27,16 @@ interface ProviderStats {
openai: number | null;
}
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'morning';
if (hour >= 12 && hour < 17) return 'afternoon';
if (hour >= 17 && hour < 21) return 'evening';
return 'night';
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -56,12 +66,25 @@ export function DashboardPage() {
const [loading, setLoading] = useState(true);
// Time-of-day state for dynamic greeting
const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>(getTimeOfDay);
const [currentTime, setCurrentTime] = useState(() => new Date());
const apiKeysCache = useRef<string[]>([]);
useEffect(() => {
apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]);
// Update time every 60 seconds
useEffect(() => {
const id = setInterval(() => {
setTimeOfDay(getTimeOfDay());
setCurrentTime(new Date());
}, 60_000);
return () => clearInterval(id);
}, []);
const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
@@ -237,113 +260,151 @@ export function DashboardPage() {
? styles.configBadgeFillFirst
: styles.configBadgeUnknown;
// Derived time-based values
const greetingKey = `dashboard.greeting_${timeOfDay}`;
const caringKey = `dashboard.caring_${timeOfDay}`;
const formattedDate = currentTime.toLocaleDateString(i18n.language, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
const formattedTime = currentTime.toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit'
});
return (
<div className={styles.dashboard}>
<div className={styles.header}>
<h1 className={styles.title}>{t('dashboard.title')}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
{/* Decorative background orbs */}
<div className={styles.backgroundOrbs} aria-hidden="true">
<div className={styles.orb1} />
<div className={styles.orb2} />
</div>
<div className={styles.connectionCard}>
<div className={styles.connectionStatus}>
<span
className={`${styles.statusDot} ${
connectionStatus === 'connected'
? styles.connected
: connectionStatus === 'connecting'
? styles.connecting
: styles.disconnected
}`}
/>
<span className={styles.statusText}>
{t(
connectionStatus === 'connected'
? 'common.connected'
: connectionStatus === 'connecting'
? 'common.connecting'
: 'common.disconnected'
)}
</span>
{/* Hero welcome section */}
<section className={styles.hero}>
<span className={styles.heroWatermark} aria-hidden="true">
OVERVIEW
</span>
<div className={styles.heroContent}>
<span className={styles.heroGreeting}>{t(greetingKey)}</span>
<h1 className={styles.heroTitle}>{t('dashboard.welcome_back')}</h1>
<p className={styles.heroCaring}>{t(caringKey)}</p>
</div>
<div className={styles.connectionInfo}>
<span className={styles.serverUrl}>{apiBase || '-'}</span>
{serverVersion && (
<span className={styles.serverVersion}>
v{serverVersion.trim().replace(/^[vV]+/, '')}
<div className={styles.heroMeta}>
<div className={styles.dateTimeBlock}>
<span className={styles.time}>{formattedTime}</span>
<span className={styles.date}>{formattedDate}</span>
</div>
<div className={styles.connectionPill}>
<span
className={`${styles.statusDot} ${
connectionStatus === 'connected'
? styles.connected
: connectionStatus === 'connecting'
? styles.connecting
: styles.disconnected
}`}
/>
<span className={styles.pillText}>
{serverVersion
? `v${serverVersion.trim().replace(/^[vV]+/, '')}`
: t(
connectionStatus === 'connected'
? 'common.connected'
: connectionStatus === 'connecting'
? 'common.connecting'
: 'common.disconnected'
)}
</span>
)}
</div>
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
</span>
)}
</div>
</div>
</section>
<div className={styles.statsGrid}>
{quickStats.map((stat) => (
<Link key={stat.path} to={stat.path} className={styles.statCard}>
<div className={styles.statIcon}>{stat.icon}</div>
<div className={styles.statContent}>
<span className={styles.statValue}>{stat.loading ? '...' : stat.value}</span>
<span className={styles.statLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.statSublabel}>{stat.sublabel}</span>
)}
</div>
</Link>
))}
</div>
{/* Bento stats grid */}
<section className={styles.statsSection}>
<h2 className={styles.sectionHeading}>{t('dashboard.system_overview')}</h2>
<div className={styles.bentoGrid}>
{quickStats.map((stat, index) => (
<Link
key={stat.path}
to={stat.path}
className={`${styles.bentoCard} ${index === 0 ? styles.bentoLarge : ''}`}
style={{ animationDelay: `${index * 80}ms` }}
>
<div className={styles.bentoIcon}>{stat.icon}</div>
<div className={styles.bentoContent}>
<span className={styles.bentoValue}>
{stat.loading ? '...' : stat.value}
</span>
<span className={styles.bentoLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.bentoSublabel}>{stat.sublabel}</span>
)}
</div>
</Link>
))}
</div>
</section>
{/* Config pills section */}
{config && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>{t('dashboard.current_config')}</h2>
<div className={styles.configGrid}>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configValue} ${config.debug ? styles.enabled : styles.disabled}`}>
<section className={styles.configSection}>
<h2 className={styles.sectionHeading}>{t('dashboard.current_config')}</h2>
<div className={styles.configPillGrid}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}>
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.usage_statistics_enable')}</span>
<span className={`${styles.configValue} ${config.usageStatisticsEnabled ? styles.enabled : styles.disabled}`}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.usage_statistics_enable')}</span>
<span className={`${styles.configPillValue} ${config.usageStatisticsEnabled ? styles.on : styles.off}`}>
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configValue} ${config.loggingToFile ? styles.enabled : styles.disabled}`}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}>
{config.loggingToFile ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configValue}>{config.requestRetry ?? 0}</span>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configPillValue}>{config.requestRetry ?? 0}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configValue} ${config.wsAuth ? styles.enabled : styles.disabled}`}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}>
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('dashboard.routing_strategy')}</span>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('dashboard.routing_strategy')}</span>
<span className={`${styles.configBadge} ${routingStrategyBadgeClass}`}>
{routingStrategyDisplay}
</span>
</div>
{config.proxyUrl && (
<div className={`${styles.configItem} ${styles.configItemFull}`}>
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configValueMono}>{config.proxyUrl}</span>
<div className={`${styles.configPill} ${styles.configPillWide}`}>
<span className={styles.configPillLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configPillMono}>{config.proxyUrl}</span>
</div>
)}
</div>
<Link to="/config" className={styles.viewMoreLink}>
{t('dashboard.edit_settings')}
</Link>
</div>
</section>
)}
</div>
);
+117 -4
View File
@@ -31,14 +31,22 @@
gap: $spacing-sm;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
min-width: 0;
:global(.btn-sm) {
:global(.btn.btn-sm) {
line-height: 16px;
min-width: 0;
}
:global(svg) {
display: block;
}
@include mobile {
width: 100%;
justify-content: stretch;
}
}
.titleWrapper {
@@ -146,9 +154,95 @@
}
.viewModeToggle {
display: flex;
display: inline-flex;
gap: $spacing-xs;
align-items: center;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
@include mobile {
flex: 1 1 auto;
width: 100%;
}
}
.viewModeButton:global(.btn.btn-sm) {
padding: 8px 14px;
border-radius: 999px;
border-color: transparent;
background: transparent;
color: var(--text-secondary);
box-shadow: none;
&:hover:not(:disabled) {
border-color: transparent;
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
color: var(--text-primary);
}
> span {
white-space: nowrap;
}
@include mobile {
flex: 1 1 0;
}
}
.viewModeButtonActive:global(.btn.btn-sm) {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast, #fff);
box-shadow: 0 8px 18px -14px rgba(0, 0, 0, 0.45);
&:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--primary-contrast, #fff);
}
}
.refreshAllButton:global(.btn.btn-sm) {
padding-inline: 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 22%, var(--border-color));
color: var(--text-primary);
> span {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 34%, var(--border-color));
}
@include mobile {
width: 100%;
justify-content: center;
}
}
:global([data-theme='dark']) .viewModeToggle {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:global([data-theme='dark']) .refreshAllButton:global(.btn.btn-sm) {
background: color-mix(in srgb, var(--primary-color) 18%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 32%, var(--border-color));
}
@include mobile {
.headerActions {
gap: $spacing-xs;
}
}
// 卡片渐变背景 基于 TYPE_COLORS light.bg rgba
@@ -243,7 +337,7 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color);
background-color: var(--quota-medium-color, #e0aa14);
}
.quotaBarFillLow {
@@ -283,6 +377,24 @@
padding: $spacing-sm 0;
}
.quotaMessageAction {
width: 100%;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
}
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
@@ -357,7 +469,8 @@
rgba(255, 237, 158, 0.58) 32%,
rgba(255, 215, 91, 0) 72%
);
filter: blur(9px);
--glass-blur: 9px;
filter: var(--glass-filter);
opacity: 0.75;
pointer-events: none;
z-index: -1;
+14 -10
View File
@@ -78,9 +78,10 @@
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
--glass-blur: 6px;
background: var(--glass-bg-secondary);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
}
.loadingOverlayContent {
@@ -1112,17 +1113,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 +1135,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 +1145,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 +1158,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}`;
}
+15 -8
View File
@@ -28,6 +28,13 @@ const extractArrayPayload = (data: unknown, key: string): unknown[] => {
return Array.isArray(candidate) ? candidate : [];
};
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
const params = new URLSearchParams();
params.set('api-key', apiKey.trim());
params.set('base-url', (baseUrl ?? '').trim());
return `?${params.toString()}`;
};
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
@@ -160,8 +167,8 @@ export const providersApi = {
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
deleteGeminiKey: (apiKey: string) =>
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
@@ -175,8 +182,8 @@ export const providersApi = {
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
deleteCodexConfig: (apiKey: string) =>
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteCodexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
@@ -190,8 +197,8 @@ export const providersApi = {
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteClaudeConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
@@ -205,8 +212,8 @@ export const providersApi = {
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteVertexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
+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) => ({
+23 -5
View File
@@ -14,9 +14,27 @@ import type { ProviderFormState } from '@/components/providers/types';
export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error';
export type ClaudeCloakBaseline = {
mode: string;
strictMode: boolean;
sensitiveWords: string[] | null;
} | null;
export type ClaudeEditBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
models: Array<{ name: string; alias: string }>;
excludedModels: string[];
cloak: ClaudeCloakBaseline;
};
type ClaudeEditDraft = {
initialized: boolean;
baselineSignature: string;
baseline: ClaudeEditBaseline | null;
form: ProviderFormState;
testModel: string;
testStatus: ClaudeTestStatus;
@@ -33,7 +51,7 @@ interface ClaudeEditDraftState {
key: string,
draft: Omit<ClaudeEditDraft, 'initialized'>
) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftBaseline: (key: string, baseline: ClaudeEditBaseline) => void;
setDraftForm: (
key: string,
action: SetStateAction<ProviderFormState>
@@ -64,7 +82,7 @@ const buildEmptyForm = (): ProviderFormState => ({
const buildEmptyDraft = (): ClaudeEditDraft => ({
initialized: false,
baselineSignature: '',
baseline: null,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
@@ -124,14 +142,14 @@ export const useClaudeEditDraftStore = create<ClaudeEditDraftState>((set, get) =
}));
},
setDraftBaselineSignature: (key, signature) => {
setDraftBaseline: (key, baseline) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
[key]: { ...existing, initialized: true, baseline },
},
};
});
+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;
}
}));
+20 -5
View File
@@ -20,9 +20,24 @@ export type KeyTestStatus = {
message: string;
};
export type OpenAIEditBaseline = {
name: string;
priority: number | null;
prefix: string;
baseUrl: string;
headers: Array<{ key: string; value: string }>;
apiKeyEntries: Array<{
apiKey: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
}>;
models: Array<{ name: string; alias: string }>;
testModel: string;
};
export type OpenAIEditDraft = {
initialized: boolean;
baselineSignature: string;
baseline: OpenAIEditBaseline | null;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
@@ -37,7 +52,7 @@ interface OpenAIEditDraftState {
releaseDraft: (key: string) => void;
ensureDraft: (key: string) => void;
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftBaseline: (key: string, baseline: OpenAIEditBaseline) => void;
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
@@ -62,7 +77,7 @@ const buildEmptyForm = (): OpenAIFormState => ({
const buildEmptyDraft = (): OpenAIEditDraft => ({
initialized: false,
baselineSignature: '',
baseline: null,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
@@ -123,14 +138,14 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
}));
},
setDraftBaselineSignature: (key, signature) => {
setDraftBaseline: (key, baseline) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
[key]: { ...existing, initialized: true, baseline },
},
};
});
+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;
+32
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;
@@ -29,6 +32,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #e0aa14;
--warning-color: #c65746; // 错误/警告色
--error-color: #c65746;
--danger-color: var(--error-color);
@@ -54,6 +58,14 @@
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
// Glass effect (blurred surfaces)
--glass-blur: 12px;
--glass-backdrop-filter: blur(var(--glass-blur));
--glass-filter: blur(var(--glass-blur));
--glass-bg: color-mix(in srgb, var(--bg-primary) 82%, transparent);
--glass-bg-secondary: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
--glass-border: color-mix(in srgb, var(--border-color) 60%, transparent);
}
// 纯白主题
@@ -64,6 +76,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;
@@ -82,6 +97,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #e0aa14;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
@@ -118,6 +134,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;
@@ -136,6 +155,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #ffd862;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
@@ -161,4 +181,16 @@
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
--glass-border: color-mix(in srgb, var(--border-color) 55%, transparent);
}
@media (max-width: 768px), (prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce) {
:root {
--glass-backdrop-filter: none;
--glass-filter: none;
--glass-bg: var(--bg-primary);
--glass-bg-secondary: var(--bg-secondary);
--glass-border: var(--border-color);
}
}
+39
View File
@@ -0,0 +1,39 @@
export function areStringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function areKeyValueEntriesEqual(
a: readonly { key: string; value: string }[],
b: readonly { key: string; value: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.key !== right.key || left.value !== right.value) return false;
}
return true;
}
export function areModelEntriesEqual(
a: readonly { name: string; alias: string }[],
b: readonly { name: string; alias: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.name !== right.name || left.alias !== right.alias) return false;
}
return true;
}
+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)
+50
View File
@@ -0,0 +1,50 @@
import type { UsageDetail } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDetailsBySource {
const map: UsageDetailsBySource = new Map();
usageDetails.forEach((detail) => {
const sourceId = detail.source;
if (!sourceId) return;
const bucket = map.get(sourceId);
if (bucket) {
bucket.push(detail);
} else {
map.set(sourceId, [detail]);
}
});
return map;
}
export function collectUsageDetailsForCandidates(
usageDetailsBySource: UsageDetailsBySource,
candidates: Iterable<string>
): UsageDetail[] {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
for (const candidate of candidates) {
const details = usageDetailsBySource.get(candidate);
if (!details || details.length === 0) continue;
if (!firstDetails) {
firstDetails = details;
continue;
}
if (!merged) {
merged = [...firstDetails];
}
for (const detail of details) {
merged.push(detail);
}
}
return merged ?? firstDetails ?? EMPTY_USAGE_DETAILS;
}