mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
19 Commits
Generated
+75
-7
@@ -13,8 +13,8 @@
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"gsap": "^3.14.2",
|
||||
"i18next": "^25.7.1",
|
||||
"motion": "^12.34.3",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.1",
|
||||
@@ -3113,6 +3113,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
|
||||
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.34.3",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3210,12 +3237,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
|
||||
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -3524,6 +3545,47 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.34.3.tgz",
|
||||
"integrity": "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.34.3",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
@@ -4025,6 +4087,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"dev": true,
|
||||
|
||||
+1
-1
@@ -17,8 +17,8 @@
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"gsap": "^3.14.2",
|
||||
"i18next": "^25.7.1",
|
||||
"motion": "^12.34.3",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import type { Notification } from '@/types';
|
||||
@@ -10,52 +11,37 @@ interface AnimatedNotification extends Notification {
|
||||
const ANIMATION_DURATION = 300; // ms
|
||||
|
||||
export function NotificationContainer() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||
const prevNotificationsRef = useRef<Notification[]>([]);
|
||||
|
||||
// Track notifications and manage animation states
|
||||
useEffect(() => {
|
||||
const prevNotifications = prevNotificationsRef.current;
|
||||
const prevIds = new Set(prevNotifications.map((n) => n.id));
|
||||
const currentIds = new Set(notifications.map((n) => n.id));
|
||||
|
||||
// Find new notifications (for enter animation)
|
||||
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
|
||||
|
||||
// Find removed notifications (for exit animation)
|
||||
const removedIds = new Set(
|
||||
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
|
||||
);
|
||||
const removedIds = new Set(prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id));
|
||||
|
||||
setAnimatedNotifications((prev) => {
|
||||
// Mark removed notifications as exiting
|
||||
let updated = prev.map((n) =>
|
||||
removedIds.has(n.id) ? { ...n, isExiting: true } : n
|
||||
);
|
||||
let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n));
|
||||
|
||||
// Add new notifications
|
||||
newNotifications.forEach((n) => {
|
||||
if (!updated.find((an) => an.id === n.id)) {
|
||||
if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) {
|
||||
updated.push({ ...n, isExiting: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove notifications that are not in current and not exiting
|
||||
// (they've already completed their exit animation)
|
||||
updated = updated.filter(
|
||||
(n) => currentIds.has(n.id) || n.isExiting
|
||||
);
|
||||
updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting);
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Clean up exited notifications after animation
|
||||
if (removedIds.size > 0) {
|
||||
setTimeout(() => {
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.filter((n) => !removedIds.has(n.id))
|
||||
);
|
||||
setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id)));
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
@@ -63,12 +49,8 @@ export function NotificationContainer() {
|
||||
}, [notifications]);
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
// Start exit animation
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||
);
|
||||
setAnimatedNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n)));
|
||||
|
||||
// Actually remove after animation
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, ANIMATION_DURATION);
|
||||
@@ -84,7 +66,12 @@ export function NotificationContainer() {
|
||||
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
|
||||
>
|
||||
<div className="message">{notification.message}</div>
|
||||
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={() => handleClose(notification.id)}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { animate } from 'motion/mini';
|
||||
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
|
||||
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
@@ -25,6 +20,20 @@ const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
|
||||
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||
const IOS_SHADOW_VALUE = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||
|
||||
const easePower2Out = (progress: number) => 1 - (1 - progress) ** 3;
|
||||
const easeCircOut = (progress: number) => Math.sqrt(1 - (progress - 1) ** 2);
|
||||
|
||||
const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`;
|
||||
const buildIosTransform = (xPercent: number, y: number) => `translate3d(${xPercent}%, ${y}px, 0px)`;
|
||||
|
||||
const clearLayerStyles = (element: HTMLElement | null) => {
|
||||
if (!element) return;
|
||||
element.style.removeProperty('transform');
|
||||
element.style.removeProperty('opacity');
|
||||
element.style.removeProperty('box-shadow');
|
||||
};
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
@@ -150,16 +159,14 @@ export function PageTransition({
|
||||
|
||||
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||
if (targetIndex !== -1) {
|
||||
const targetStack: Layer[] = prev
|
||||
.slice(0, targetIndex + 1)
|
||||
.map((layer, idx): Layer => {
|
||||
const isTarget = idx === targetIndex;
|
||||
return {
|
||||
...layer,
|
||||
location: isTarget ? location : layer.location,
|
||||
status: isTarget ? 'current' : 'stacked',
|
||||
};
|
||||
});
|
||||
const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
|
||||
const isTarget = idx === targetIndex;
|
||||
return {
|
||||
...layer,
|
||||
location: isTarget ? location : layer.location,
|
||||
status: isTarget ? 'current' : 'stacked',
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = targetStack;
|
||||
@@ -194,7 +201,7 @@ export function PageTransition({
|
||||
layers,
|
||||
]);
|
||||
|
||||
// Run GSAP animation when animating starts
|
||||
// Run Motion animation when animating starts
|
||||
useLayoutEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
@@ -204,10 +211,8 @@ export function PageTransition({
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
const transitionVariant = transitionVariantRef.current;
|
||||
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
clearLayerStyles(currentLayerEl);
|
||||
clearLayerStyles(exitingLayerEl);
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||
@@ -221,22 +226,21 @@ export function PageTransition({
|
||||
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||
const activeAnimations: AnimationPlaybackControlsWithThen[] = [];
|
||||
let cancelled = false;
|
||||
let completed = false;
|
||||
const completeTransition = () => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
const nextLayers = nextLayersRef.current;
|
||||
nextLayersRef.current = null;
|
||||
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
const nextLayers = nextLayersRef.current;
|
||||
nextLayersRef.current = null;
|
||||
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
},
|
||||
});
|
||||
clearLayerStyles(currentLayerEl);
|
||||
clearLayerStyles(exitingLayerEl);
|
||||
};
|
||||
|
||||
if (transitionVariant === 'ios') {
|
||||
const exitToXPercent = isForward
|
||||
@@ -247,90 +251,104 @@ export function PageTransition({
|
||||
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, {
|
||||
y: exitBaseY,
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY);
|
||||
exitingLayerEl.style.opacity = '1';
|
||||
}
|
||||
|
||||
gsap.set(currentLayerEl, {
|
||||
xPercent: enterFromXPercent,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||
currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0);
|
||||
currentLayerEl.style.opacity = '1';
|
||||
|
||||
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||
if (topLayerEl) {
|
||||
gsap.set(topLayerEl, { boxShadow: shadowValue });
|
||||
topLayerEl.style.boxShadow = IOS_SHADOW_VALUE;
|
||||
}
|
||||
|
||||
if (exitingLayerEl) {
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
xPercent: exitToXPercent,
|
||||
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
exitingLayerEl,
|
||||
{
|
||||
transform: [
|
||||
buildIosTransform(0, exitBaseY),
|
||||
buildIosTransform(exitToXPercent, exitBaseY),
|
||||
],
|
||||
opacity: [1, isForward ? IOS_EXIT_DIM_OPACITY : 1],
|
||||
},
|
||||
{
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: easePower2Out,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
tl.to(
|
||||
currentLayerEl,
|
||||
{
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
currentLayerEl,
|
||||
{
|
||||
transform: [buildIosTransform(enterFromXPercent, 0), buildIosTransform(0, 0)],
|
||||
opacity: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: easePower2Out,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY);
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
exitingLayerEl,
|
||||
{
|
||||
transform: [
|
||||
buildVerticalTransform(exitBaseY),
|
||||
buildVerticalTransform(exitBaseY + exitToY),
|
||||
],
|
||||
opacity: [1, 0],
|
||||
},
|
||||
{
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: easeCircOut,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||
tl.fromTo(
|
||||
currentLayerEl,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
onComplete: () => {
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||
}
|
||||
currentLayerEl.style.transform = buildVerticalTransform(enterFromY);
|
||||
currentLayerEl.style.opacity = '0';
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
currentLayerEl,
|
||||
{
|
||||
transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)],
|
||||
opacity: [0, 1],
|
||||
},
|
||||
},
|
||||
0
|
||||
{
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: easeCircOut,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeAnimations.length) {
|
||||
completeTransition();
|
||||
} else {
|
||||
void Promise.all(
|
||||
activeAnimations.map((animation) => animation.finished.catch(() => undefined))
|
||||
).then(() => {
|
||||
if (cancelled) return;
|
||||
completeTransition();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||
cancelled = true;
|
||||
activeAnimations.forEach((animation) => animation.stop());
|
||||
};
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
@@ -348,30 +366,30 @@ export function PageTransition({
|
||||
return layers.map((layer, index) => {
|
||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||
return (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
: layer.status === 'current'
|
||||
? currentLayerRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
<div
|
||||
key={layer.key}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
: layer.status === 'current'
|
||||
? currentLayerRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
$diff-mono: 'Consolas', 'Monaco', 'Menlo', 'SF Mono', monospace;
|
||||
$diff-font-size: 12px;
|
||||
$diff-line-height: 20px;
|
||||
$diff-gutter-width: 50px;
|
||||
$diff-prefix-width: 20px;
|
||||
|
||||
// GitHub-inspired diff colors (theme-adaptive via color-mix)
|
||||
$diff-add-color: #3fb950;
|
||||
$diff-del-color: #f85149;
|
||||
$diff-hunk-color: #388bfd;
|
||||
|
||||
.diffModal {
|
||||
:global(.modal-body) {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -25,152 +36,264 @@
|
||||
font-size: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: $spacing-md;
|
||||
}
|
||||
|
||||
.diffList {
|
||||
// ── File container ──────────────────────────────────────
|
||||
|
||||
.diffContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.diffCard {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diffCardHeader {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||
}
|
||||
// ── File header ─────────────────────────────────────────
|
||||
|
||||
.diffColumns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.diffColumn {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.diffColumnHeader {
|
||||
.fileHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.lineMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: $spacing-sm;
|
||||
padding: 10px $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lineRange {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.contextRange {
|
||||
font-size: 11px;
|
||||
.fileIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.codeList {
|
||||
.fileName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: $diff-mono;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fileStats {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: $diff-mono;
|
||||
}
|
||||
|
||||
.statAdditions {
|
||||
color: $diff-add-color;
|
||||
}
|
||||
|
||||
.statDeletions {
|
||||
color: $diff-del-color;
|
||||
}
|
||||
|
||||
.statBar {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.statBlock {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.statBlockAdd {
|
||||
background: $diff-add-color;
|
||||
}
|
||||
|
||||
.statBlockDel {
|
||||
background: $diff-del-color;
|
||||
}
|
||||
|
||||
// ── Diff body (scrollable) ──────────────────────────────
|
||||
|
||||
.diffBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-height: 280px;
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
font-family: $diff-mono;
|
||||
font-size: $diff-font-size;
|
||||
line-height: $diff-line-height;
|
||||
}
|
||||
|
||||
.codeLine {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
// ── Hunk ────────────────────────────────────────────────
|
||||
|
||||
.hunk + .hunk {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.codeLine:first-child {
|
||||
border-top: none;
|
||||
.hunkHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, $diff-hunk-color 8%, var(--bg-primary));
|
||||
border-bottom: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
|
||||
color: color-mix(in srgb, $diff-hunk-color 75%, var(--text-secondary));
|
||||
min-height: $diff-line-height;
|
||||
}
|
||||
|
||||
.codeLineChanged {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
.hunkGutter {
|
||||
width: $diff-gutter-width;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
border-right: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
|
||||
}
|
||||
|
||||
.codeLineNumber {
|
||||
padding: 7px 10px 7px 8px;
|
||||
.hunkExpandIcon {
|
||||
color: color-mix(in srgb, $diff-hunk-color 70%, var(--text-tertiary));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hunkText {
|
||||
padding: 4px $spacing-sm 4px ($diff-prefix-width + $spacing-sm);
|
||||
font-size: $diff-font-size;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ── Diff line ───────────────────────────────────────────
|
||||
|
||||
.diffLine {
|
||||
display: flex;
|
||||
min-height: $diff-line-height;
|
||||
}
|
||||
|
||||
// ── Line number gutters ─────────────────────────────────
|
||||
|
||||
.lineNum {
|
||||
width: $diff-gutter-width;
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
border-right: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-right: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.codeLineText {
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
.lineNumEmpty {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
// ── Prefix column (+/-/space) ───────────────────────────
|
||||
|
||||
.linePrefix {
|
||||
width: $diff-prefix-width;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ── Code text ───────────────────────────────────────────
|
||||
|
||||
.lineText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ── Context lines ───────────────────────────────────────
|
||||
|
||||
.context {
|
||||
background: var(--bg-primary);
|
||||
|
||||
.linePrefix {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.lineText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deletion lines ──────────────────────────────────────
|
||||
|
||||
.deletion {
|
||||
background: color-mix(in srgb, $diff-del-color 8%, var(--bg-primary));
|
||||
|
||||
.lineNum {
|
||||
background: color-mix(in srgb, $diff-del-color 12%, var(--bg-primary));
|
||||
border-right-color: color-mix(in srgb, $diff-del-color 18%, var(--border-color));
|
||||
color: color-mix(in srgb, $diff-del-color 60%, var(--text-tertiary));
|
||||
}
|
||||
|
||||
.linePrefix {
|
||||
color: $diff-del-color;
|
||||
}
|
||||
|
||||
.lineText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Addition lines ──────────────────────────────────────
|
||||
|
||||
.addition {
|
||||
background: color-mix(in srgb, $diff-add-color 8%, var(--bg-primary));
|
||||
|
||||
.lineNum {
|
||||
background: color-mix(in srgb, $diff-add-color 12%, var(--bg-primary));
|
||||
border-right-color: color-mix(in srgb, $diff-add-color 18%, var(--border-color));
|
||||
color: color-mix(in srgb, $diff-add-color 60%, var(--text-tertiary));
|
||||
}
|
||||
|
||||
.linePrefix {
|
||||
color: $diff-add-color;
|
||||
}
|
||||
|
||||
.lineText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile responsive ───────────────────────────────────
|
||||
|
||||
@include mobile {
|
||||
.content {
|
||||
height: 65vh;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.diffColumns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lineMeta {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.codeLine {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.codeLineNumber {
|
||||
padding: 6px 6px 6px 4px;
|
||||
.lineNum {
|
||||
width: 36px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.codeLineText {
|
||||
padding: 6px 8px;
|
||||
.linePrefix {
|
||||
width: 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hunkGutter {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.hunkText {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.diffBody {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fileStats {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
+236
-123
@@ -15,66 +15,189 @@ type DiffModalProps = {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type DiffChunkCard = {
|
||||
id: string;
|
||||
current: DiffSide;
|
||||
modified: DiffSide;
|
||||
};
|
||||
type UnifiedLineType = 'context' | 'addition' | 'deletion';
|
||||
|
||||
type LineRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type DiffSideLine = {
|
||||
lineNumber: number;
|
||||
type UnifiedLine = {
|
||||
type: UnifiedLineType;
|
||||
oldNum: number | null;
|
||||
newNum: number | null;
|
||||
text: string;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
type DiffSide = {
|
||||
changedRangeLabel: string;
|
||||
contextRangeLabel: string;
|
||||
lines: DiffSideLine[];
|
||||
type Hunk = {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: UnifiedLine[];
|
||||
};
|
||||
|
||||
const DIFF_CONTEXT_LINES = 2;
|
||||
type DiffResult = {
|
||||
hunks: Hunk[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
const DIFF_CONTEXT_LINES = 3;
|
||||
|
||||
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
|
||||
|
||||
const getLineRangeLabel = (range: LineRange): string => {
|
||||
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
||||
};
|
||||
function computeUnifiedDiff(original: string, modified: string): DiffResult {
|
||||
const oldDoc = Text.of(original.split('\n'));
|
||||
const newDoc = Text.of(modified.split('\n'));
|
||||
const chunks = Chunk.build(oldDoc, newDoc);
|
||||
|
||||
const getChangedLineRange = (doc: Text, from: number, to: number): LineRange => {
|
||||
const start = clampPos(doc, from);
|
||||
const end = clampPos(doc, to);
|
||||
if (start === end) {
|
||||
const linePos = Math.min(start, doc.length);
|
||||
const anchorLine = doc.lineAt(linePos).number;
|
||||
return { start: anchorLine, end: anchorLine };
|
||||
}
|
||||
const startLine = doc.lineAt(start).number;
|
||||
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
|
||||
return { start: startLine, end: endLine };
|
||||
};
|
||||
let totalAdditions = 0;
|
||||
let totalDeletions = 0;
|
||||
|
||||
const expandContextRange = (doc: Text, range: LineRange): LineRange => ({
|
||||
start: Math.max(1, range.start - DIFF_CONTEXT_LINES),
|
||||
end: Math.min(doc.lines, range.end + DIFF_CONTEXT_LINES)
|
||||
});
|
||||
const hunks: Hunk[] = chunks.map((chunk) => {
|
||||
const lines: UnifiedLine[] = [];
|
||||
|
||||
const buildSideLines = (doc: Text, contextRange: LineRange, changedRange: LineRange): DiffSideLine[] => {
|
||||
const lines: DiffSideLine[] = [];
|
||||
for (let lineNumber = contextRange.start; lineNumber <= contextRange.end; lineNumber += 1) {
|
||||
lines.push({
|
||||
lineNumber,
|
||||
text: doc.line(lineNumber).text,
|
||||
changed: lineNumber >= changedRange.start && lineNumber <= changedRange.end
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
const hasDel = chunk.fromA < chunk.toA;
|
||||
const hasAdd = chunk.fromB < chunk.toB;
|
||||
|
||||
// Collect deleted lines from old doc
|
||||
const delLines: { num: number; text: string }[] = [];
|
||||
if (hasDel) {
|
||||
const startLine = oldDoc.lineAt(chunk.fromA).number;
|
||||
const endLine = oldDoc.lineAt(chunk.toA - 1).number;
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
delLines.push({ num: i, text: oldDoc.line(i).text });
|
||||
}
|
||||
}
|
||||
|
||||
// Collect added lines from new doc
|
||||
const addLines: { num: number; text: string }[] = [];
|
||||
if (hasAdd) {
|
||||
const startLine = newDoc.lineAt(chunk.fromB).number;
|
||||
const endLine = newDoc.lineAt(chunk.toB - 1).number;
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
addLines.push({ num: i, text: newDoc.line(i).text });
|
||||
}
|
||||
}
|
||||
|
||||
totalDeletions += delLines.length;
|
||||
totalAdditions += addLines.length;
|
||||
|
||||
// Compute context boundaries
|
||||
let ctxBeforeEndOld: number;
|
||||
let ctxAfterStartOld: number;
|
||||
let ctxBeforeEndNew: number;
|
||||
let ctxAfterStartNew: number;
|
||||
|
||||
if (hasDel) {
|
||||
ctxBeforeEndOld = delLines[0].num - 1;
|
||||
ctxAfterStartOld = delLines[delLines.length - 1].num + 1;
|
||||
} else {
|
||||
const anchorPos = clampPos(oldDoc, chunk.fromA);
|
||||
const lineInfo = oldDoc.lineAt(anchorPos);
|
||||
if (chunk.fromA === lineInfo.from) {
|
||||
ctxBeforeEndOld = lineInfo.number - 1;
|
||||
ctxAfterStartOld = lineInfo.number;
|
||||
} else {
|
||||
ctxBeforeEndOld = lineInfo.number;
|
||||
ctxAfterStartOld = lineInfo.number + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAdd) {
|
||||
ctxBeforeEndNew = addLines[0].num - 1;
|
||||
ctxAfterStartNew = addLines[addLines.length - 1].num + 1;
|
||||
} else {
|
||||
const anchorPos = clampPos(newDoc, chunk.fromB);
|
||||
const lineInfo = newDoc.lineAt(anchorPos);
|
||||
if (chunk.fromB === lineInfo.from) {
|
||||
ctxBeforeEndNew = lineInfo.number - 1;
|
||||
ctxAfterStartNew = lineInfo.number;
|
||||
} else {
|
||||
ctxBeforeEndNew = lineInfo.number;
|
||||
ctxAfterStartNew = lineInfo.number + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Context before
|
||||
const ctxBeforeCount = Math.min(
|
||||
DIFF_CONTEXT_LINES,
|
||||
Math.max(0, ctxBeforeEndOld),
|
||||
Math.max(0, ctxBeforeEndNew)
|
||||
);
|
||||
|
||||
for (let i = ctxBeforeCount; i > 0; i--) {
|
||||
const oldNum = ctxBeforeEndOld - i + 1;
|
||||
const newNum = ctxBeforeEndNew - i + 1;
|
||||
if (oldNum >= 1 && newNum >= 1 && oldNum <= oldDoc.lines) {
|
||||
lines.push({
|
||||
type: 'context',
|
||||
oldNum,
|
||||
newNum,
|
||||
text: oldDoc.line(oldNum).text
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deletions
|
||||
for (const del of delLines) {
|
||||
lines.push({ type: 'deletion', oldNum: del.num, newNum: null, text: del.text });
|
||||
}
|
||||
|
||||
// Additions
|
||||
for (const add of addLines) {
|
||||
lines.push({ type: 'addition', oldNum: null, newNum: add.num, text: add.text });
|
||||
}
|
||||
|
||||
// Context after
|
||||
const ctxAfterCountOld = Math.max(
|
||||
0,
|
||||
Math.min(DIFF_CONTEXT_LINES, oldDoc.lines - ctxAfterStartOld + 1)
|
||||
);
|
||||
const ctxAfterCountNew = Math.max(
|
||||
0,
|
||||
Math.min(DIFF_CONTEXT_LINES, newDoc.lines - ctxAfterStartNew + 1)
|
||||
);
|
||||
const ctxAfterCount = Math.min(ctxAfterCountOld, ctxAfterCountNew);
|
||||
|
||||
for (let i = 0; i < ctxAfterCount; i++) {
|
||||
const oldNum = ctxAfterStartOld + i;
|
||||
const newNum = ctxAfterStartNew + i;
|
||||
if (oldNum >= 1 && oldNum <= oldDoc.lines && newNum >= 1 && newNum <= newDoc.lines) {
|
||||
lines.push({
|
||||
type: 'context',
|
||||
oldNum,
|
||||
newNum,
|
||||
text: oldDoc.line(oldNum).text
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compute hunk header values
|
||||
const firstOld = lines.find((l) => l.oldNum !== null)?.oldNum ?? 1;
|
||||
const firstNew = lines.find((l) => l.newNum !== null)?.newNum ?? 1;
|
||||
const oldCount = lines.filter((l) => l.type !== 'addition').length;
|
||||
const newCount = lines.filter((l) => l.type !== 'deletion').length;
|
||||
|
||||
return { oldStart: firstOld, oldCount, newStart: firstNew, newCount, lines };
|
||||
});
|
||||
|
||||
return { hunks, additions: totalAdditions, deletions: totalDeletions };
|
||||
}
|
||||
|
||||
const STAT_BLOCKS = 5;
|
||||
|
||||
function StatBar({ additions, deletions }: { additions: number; deletions: number }) {
|
||||
const total = additions + deletions;
|
||||
if (total === 0) return null;
|
||||
const addBlocks = Math.round((additions / total) * STAT_BLOCKS);
|
||||
return (
|
||||
<span className={styles.statBar}>
|
||||
{Array.from({ length: STAT_BLOCKS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${styles.statBlock} ${i < addBlocks ? styles.statBlockAdd : styles.statBlockDel}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffModal({
|
||||
open,
|
||||
@@ -86,32 +209,10 @@ export function DiffModal({
|
||||
}: DiffModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const diffCards = useMemo<DiffChunkCard[]>(() => {
|
||||
const currentDoc = Text.of(original.split('\n'));
|
||||
const modifiedDoc = Text.of(modified.split('\n'));
|
||||
const chunks = Chunk.build(currentDoc, modifiedDoc);
|
||||
|
||||
return chunks.map((chunk, index) => {
|
||||
const currentChangedRange = getChangedLineRange(currentDoc, chunk.fromA, chunk.toA);
|
||||
const modifiedChangedRange = getChangedLineRange(modifiedDoc, chunk.fromB, chunk.toB);
|
||||
const currentContextRange = expandContextRange(currentDoc, currentChangedRange);
|
||||
const modifiedContextRange = expandContextRange(modifiedDoc, modifiedChangedRange);
|
||||
|
||||
return {
|
||||
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
|
||||
current: {
|
||||
changedRangeLabel: getLineRangeLabel(currentChangedRange),
|
||||
contextRangeLabel: getLineRangeLabel(currentContextRange),
|
||||
lines: buildSideLines(currentDoc, currentContextRange, currentChangedRange)
|
||||
},
|
||||
modified: {
|
||||
changedRangeLabel: getLineRangeLabel(modifiedChangedRange),
|
||||
contextRangeLabel: getLineRangeLabel(modifiedContextRange),
|
||||
lines: buildSideLines(modifiedDoc, modifiedContextRange, modifiedChangedRange)
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [modified, original]);
|
||||
const diff = useMemo<DiffResult>(
|
||||
() => computeUnifiedDiff(original, modified),
|
||||
[original, modified]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -133,61 +234,73 @@ export function DiffModal({
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{diffCards.length === 0 ? (
|
||||
{diff.hunks.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
|
||||
) : (
|
||||
<div className={styles.diffList}>
|
||||
{diffCards.map((card, index) => (
|
||||
<article key={card.id} className={styles.diffCard}>
|
||||
<div className={styles.diffCardHeader}>#{index + 1}</div>
|
||||
<div className={styles.diffColumns}>
|
||||
<section className={styles.diffColumn}>
|
||||
<header className={styles.diffColumnHeader}>
|
||||
<span>{t('config_management.diff.current')}</span>
|
||||
<span className={styles.lineMeta}>
|
||||
<span className={styles.lineRange}>L{card.current.changedRangeLabel}</span>
|
||||
<span className={styles.contextRange}>
|
||||
±{DIFF_CONTEXT_LINES}: L{card.current.contextRangeLabel}
|
||||
</span>
|
||||
<div className={styles.diffContainer}>
|
||||
<div className={styles.fileHeader}>
|
||||
<svg className={styles.fileIcon} viewBox="0 0 16 16" width="16" height="16">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className={styles.fileName}>config.yaml</span>
|
||||
<span className={styles.fileStats}>
|
||||
<span className={styles.statAdditions}>+{diff.additions}</span>
|
||||
<span className={styles.statDeletions}>-{diff.deletions}</span>
|
||||
<StatBar additions={diff.additions} deletions={diff.deletions} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.diffBody}>
|
||||
{diff.hunks.map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className={styles.hunk}>
|
||||
<div className={styles.hunkHeader}>
|
||||
<span className={styles.hunkGutter}>
|
||||
<svg
|
||||
className={styles.hunkExpandIcon}
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<path
|
||||
d="M8.177 1.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V5H5.104a.25.25 0 01-.177-.427l2.896-2.896a.25.25 0 01.354 0zM7.25 11.75a.75.75 0 011.5 0V13h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 13H7.25v-1.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className={styles.hunkGutter} />
|
||||
<span className={styles.hunkText}>
|
||||
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={`${hunkIdx}-${lineIdx}`}
|
||||
className={`${styles.diffLine} ${styles[line.type]}`}
|
||||
>
|
||||
<span
|
||||
className={`${styles.lineNum} ${line.oldNum === null ? styles.lineNumEmpty : ''}`}
|
||||
>
|
||||
{line.oldNum ?? ''}
|
||||
</span>
|
||||
</header>
|
||||
<div className={styles.codeList}>
|
||||
{card.current.lines.map((line) => (
|
||||
<div
|
||||
key={`${card.id}-a-${line.lineNumber}`}
|
||||
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||
>
|
||||
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.diffColumn}>
|
||||
<header className={styles.diffColumnHeader}>
|
||||
<span>{t('config_management.diff.modified')}</span>
|
||||
<span className={styles.lineMeta}>
|
||||
<span className={styles.lineRange}>L{card.modified.changedRangeLabel}</span>
|
||||
<span className={styles.contextRange}>
|
||||
±{DIFF_CONTEXT_LINES}: L{card.modified.contextRangeLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.lineNum} ${line.newNum === null ? styles.lineNumEmpty : ''}`}
|
||||
>
|
||||
{line.newNum ?? ''}
|
||||
</span>
|
||||
</header>
|
||||
<div className={styles.codeList}>
|
||||
{card.modified.lines.map((line) => (
|
||||
<div
|
||||
key={`${card.id}-b-${line.lineNumber}`}
|
||||
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||
>
|
||||
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||
</div>
|
||||
))}
|
||||
<span className={styles.linePrefix}>
|
||||
{line.type === 'deletion' ? '-' : line.type === 'addition' ? '+' : ' '}
|
||||
</span>
|
||||
<code className={styles.lineText}>{line.text || ' '}</code>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleParamGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.payloadJsonInput {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.payloadParamError {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
@@ -23,6 +39,16 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
:global(.input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
@@ -31,6 +57,11 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useId, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadModelEntry,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValueType,
|
||||
PayloadParamValidationErrorCode,
|
||||
PayloadRule,
|
||||
VisualConfigValidationErrorCode,
|
||||
VisualConfigValidationErrors,
|
||||
VisualConfigValues,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import {
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS,
|
||||
} from '@/hooks/useVisualConfig';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
ApiKeysCardEditor,
|
||||
PayloadFilterRulesEditor,
|
||||
PayloadRulesEditor,
|
||||
} from './VisualConfigEditorBlocks';
|
||||
|
||||
interface VisualConfigEditorProps {
|
||||
values: VisualConfigValues;
|
||||
validationErrors?: VisualConfigValidationErrors;
|
||||
disabled?: boolean;
|
||||
onChange: (values: Partial<VisualConfigValues>) => void;
|
||||
}
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: VisualConfigValidationErrorCode | PayloadParamValidationErrorCode
|
||||
) {
|
||||
if (!errorCode) return undefined;
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
type ToggleRowProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -81,647 +83,43 @@ function Divider() {
|
||||
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
|
||||
}
|
||||
|
||||
function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean),
|
||||
[value]
|
||||
);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
function generateSecureApiKey(): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(17);
|
||||
crypto.getRandomValues(array);
|
||||
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingIndex(null);
|
||||
setInputValue('');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInputValue(apiKeys[index] ?? '');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingIndex(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const updateApiKeys = (nextKeys: string[]) => {
|
||||
onChange(nextKeys.join('\n'));
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
updateApiKeys(apiKeys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
setFormError(t('config_management.visual.api_keys.error_empty'));
|
||||
return;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
setFormError(t('config_management.visual.api_keys.error_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKeys =
|
||||
editingIndex === null
|
||||
? [...apiKeys, trimmed]
|
||||
: apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key));
|
||||
updateApiKeys(nextKeys);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCopy = async (apiKey: string) => {
|
||||
const copied = await copyToClipboard(apiKey);
|
||||
showNotification(
|
||||
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
setInputValue(generateSecureApiKey());
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disabled}>
|
||||
{t('config_management.visual.api_keys.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.api_keys.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="item-list" style={{ marginTop: 4 }}>
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={`${key}-${index}`} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">API Key</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDelete(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hint">{t('config_management.visual.api_keys.hint')}</div>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingIndex !== null ? t('config_management.visual.api_keys.edit_title') : t('config_management.visual.api_keys.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
|
||||
{t('config_management.visual.common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{editingIndex !== null ? t('config_management.visual.common.update') : t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('config_management.visual.api_keys.input_label')}
|
||||
placeholder={t('config_management.visual.api_keys.input_placeholder')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
error={formError || undefined}
|
||||
hint={t('config_management.visual.api_keys.input_hint')}
|
||||
style={{ paddingRight: 148 }}
|
||||
rightElement={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StringListEditor({
|
||||
value,
|
||||
disabled,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = value.length ? value : [];
|
||||
|
||||
const updateItem = (index: number, nextValue: string) =>
|
||||
onChange(items.map((item, i) => (i === index ? nextValue : item)));
|
||||
const addItem = () => onChange([...items, '']);
|
||||
const removeItem = (index: number) => onChange(items.filter((_, i) => i !== index));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
const addParam = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: 'string',
|
||||
value: '',
|
||||
};
|
||||
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
|
||||
};
|
||||
|
||||
const removeParam = (ruleIndex: number, paramIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
|
||||
};
|
||||
|
||||
const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial<PayloadParamEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
|
||||
});
|
||||
};
|
||||
|
||||
const getValuePlaceholder = (valueType: PayloadParamValueType) => {
|
||||
switch (valueType) {
|
||||
case 'string':
|
||||
return t('config_management.visual.payload_rules.value_string');
|
||||
case 'number':
|
||||
return t('config_management.visual.payload_rules.value_number');
|
||||
case 'boolean':
|
||||
return t('config_management.visual.payload_rules.value_boolean');
|
||||
case 'json':
|
||||
return t('config_management.visual.payload_rules.value_json');
|
||||
default:
|
||||
return t('config_management.visual.payload_rules.value_default');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
|
||||
<div key={param.id} className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, { valueType: nextValue as PayloadParamValueType })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addParam(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_param')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayloadFilterRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadFilterRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadFilterRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.remove_params')}</div>
|
||||
<StringListEditor
|
||||
value={rule.params}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
onChange={(params) => updateRule(ruleIndex, { params })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualConfigEditor({ values, disabled = false, onChange }: VisualConfigEditorProps) {
|
||||
export function VisualConfigEditor({ values, validationErrors, disabled = false, onChange }: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const routingStrategyLabelId = useId();
|
||||
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
|
||||
const keepaliveInputId = useId();
|
||||
const keepaliveHintId = `${keepaliveInputId}-hint`;
|
||||
const keepaliveErrorId = `${keepaliveInputId}-error`;
|
||||
const nonstreamKeepaliveInputId = useId();
|
||||
const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`;
|
||||
const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`;
|
||||
const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
|
||||
const isNonstreamKeepaliveDisabled =
|
||||
values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0';
|
||||
const portError = getValidationMessage(t, validationErrors?.port);
|
||||
const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb);
|
||||
const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry);
|
||||
const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
|
||||
const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']);
|
||||
const bootstrapRetriesError = getValidationMessage(t, validationErrors?.['streaming.bootstrapRetries']);
|
||||
const nonstreamKeepaliveError = getValidationMessage(
|
||||
t,
|
||||
validationErrors?.['streaming.nonstreamKeepaliveInterval']
|
||||
);
|
||||
|
||||
const handleApiKeysTextChange = useCallback((apiKeysText: string) => onChange({ apiKeysText }), [onChange]);
|
||||
const handlePayloadDefaultRulesChange = useCallback(
|
||||
(payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePayloadOverrideRulesChange = useCallback(
|
||||
(payloadOverrideRules: PayloadRule[]) => onChange({ payloadOverrideRules }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePayloadFilterRulesChange = useCallback(
|
||||
(payloadFilterRules: PayloadFilterRule[]) => onChange({ payloadFilterRules }),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
@@ -741,6 +139,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.port}
|
||||
onChange={(e) => onChange({ port: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={portError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</ConfigSection>
|
||||
@@ -827,7 +226,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<ApiKeysCardEditor
|
||||
value={values.apiKeysText}
|
||||
disabled={disabled}
|
||||
onChange={(apiKeysText) => onChange({ apiKeysText })}
|
||||
onChange={handleApiKeysTextChange}
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
@@ -873,6 +272,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.logsMaxTotalSizeMb}
|
||||
onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={logsMaxSizeError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</div>
|
||||
@@ -895,6 +295,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.requestRetry}
|
||||
onChange={(e) => onChange({ requestRetry: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={requestRetryError}
|
||||
/>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.network.max_retry_interval')}
|
||||
@@ -903,22 +304,25 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.maxRetryInterval}
|
||||
onChange={(e) => onChange({ maxRetryInterval: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={maxRetryIntervalError}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
||||
<label id={routingStrategyLabelId} htmlFor={`${routingStrategyLabelId}-select`}>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
||||
<Select
|
||||
value={values.routingStrategy}
|
||||
options={[
|
||||
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
|
||||
{ value: 'fill-first', label: t('config_management.visual.sections.network.strategy_fill_first') },
|
||||
]}
|
||||
id={`${routingStrategyLabelId}-select`}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.sections.network.routing_strategy')}
|
||||
ariaLabelledBy={routingStrategyLabelId}
|
||||
ariaDescribedBy={routingStrategyHintId}
|
||||
onChange={(nextValue) =>
|
||||
onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] })
|
||||
}
|
||||
/>
|
||||
<div className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
|
||||
<div id={routingStrategyHintId} className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
|
||||
</div>
|
||||
</SectionGrid>
|
||||
|
||||
@@ -962,9 +366,10 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<SectionGrid>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
|
||||
<label htmlFor={keepaliveInputId}>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={keepaliveInputId}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
@@ -993,7 +398,8 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
|
||||
{keepaliveError && <div id={keepaliveErrorId} className="error-box">{keepaliveError}</div>}
|
||||
<div id={keepaliveHintId} className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.streaming.bootstrap_retries')}
|
||||
@@ -1003,12 +409,13 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
onChange={(e) => onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })}
|
||||
disabled={disabled}
|
||||
hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
|
||||
error={bootstrapRetriesError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
|
||||
<SectionGrid>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
|
||||
<label htmlFor={nonstreamKeepaliveInputId}>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
className="input"
|
||||
@@ -1041,7 +448,8 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">
|
||||
{nonstreamKeepaliveError && <div id={nonstreamKeepaliveErrorId} className="error-box">{nonstreamKeepaliveError}</div>}
|
||||
<div id={nonstreamKeepaliveHintId} className="hint">
|
||||
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1059,7 +467,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<PayloadRulesEditor
|
||||
value={values.payloadDefaultRules}
|
||||
disabled={disabled}
|
||||
onChange={(payloadDefaultRules) => onChange({ payloadDefaultRules })}
|
||||
onChange={handlePayloadDefaultRulesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1072,7 +480,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.payloadOverrideRules}
|
||||
disabled={disabled}
|
||||
protocolFirst
|
||||
onChange={(payloadOverrideRules) => onChange({ payloadOverrideRules })}
|
||||
onChange={handlePayloadOverrideRulesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1084,7 +492,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<PayloadFilterRulesEditor
|
||||
value={values.payloadFilterRules}
|
||||
disabled={disabled}
|
||||
onChange={(payloadFilterRules) => onChange({ payloadFilterRules })}
|
||||
onChange={handlePayloadFilterRulesChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadModelEntry,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValidationErrorCode,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import {
|
||||
getPayloadParamValidationError,
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS,
|
||||
} from '@/hooks/useVisualConfig';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: PayloadParamValidationErrorCode
|
||||
) {
|
||||
if (!errorCode) return undefined;
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean),
|
||||
[value]
|
||||
);
|
||||
const [apiKeyIds, setApiKeyIds] = useState(() => apiKeys.map(() => makeClientId()));
|
||||
const renderApiKeyIds = useMemo(() => {
|
||||
if (apiKeyIds.length === apiKeys.length) return apiKeyIds;
|
||||
if (apiKeyIds.length > apiKeys.length) return apiKeyIds.slice(0, apiKeys.length);
|
||||
return [...apiKeyIds, ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId())];
|
||||
}, [apiKeyIds, apiKeys.length]);
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
const apiKeyHintId = `${apiKeyInputId}-hint`;
|
||||
const apiKeyErrorId = `${apiKeyInputId}-error`;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingApiKeyId, setEditingApiKeyId] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
function generateSecureApiKey(): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(17);
|
||||
crypto.getRandomValues(array);
|
||||
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingApiKeyId(null);
|
||||
setInputValue('');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (apiKeyId: string) => {
|
||||
const editingIndex = renderApiKeyIds.findIndex((id) => id === apiKeyId);
|
||||
setEditingApiKeyId(apiKeyId);
|
||||
setInputValue(apiKeys[editingIndex] ?? '');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingApiKeyId(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const updateApiKeys = (nextKeys: string[]) => {
|
||||
onChange(nextKeys.join('\n'));
|
||||
};
|
||||
|
||||
const handleDelete = (apiKeyId: string) => {
|
||||
const index = renderApiKeyIds.findIndex((id) => id === apiKeyId);
|
||||
if (index < 0) return;
|
||||
setApiKeyIds(renderApiKeyIds.filter((id) => id !== apiKeyId));
|
||||
updateApiKeys(apiKeys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
setFormError(t('config_management.visual.api_keys.error_empty'));
|
||||
return;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
setFormError(t('config_management.visual.api_keys.error_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const editingIndex = editingApiKeyId ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) : -1;
|
||||
const nextKeys =
|
||||
editingApiKeyId === null
|
||||
? [...apiKeys, trimmed]
|
||||
: apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key));
|
||||
if (editingApiKeyId === null) {
|
||||
setApiKeyIds([...renderApiKeyIds, makeClientId()]);
|
||||
}
|
||||
updateApiKeys(nextKeys);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCopy = async (apiKey: string) => {
|
||||
const copied = await copyToClipboard(apiKey);
|
||||
showNotification(
|
||||
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
setInputValue(generateSecureApiKey());
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disabled}>
|
||||
{t('config_management.visual.api_keys.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.api_keys.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="item-list" style={{ marginTop: 4 }}>
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={renderApiKeyIds[index] ?? `${key}-${index}`} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">{t('config_management.visual.api_keys.input_label')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(renderApiKeyIds[index] ?? '')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(renderApiKeyIds[index] ?? '')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hint">{t('config_management.visual.api_keys.hint')}</div>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingApiKeyId !== null ? t('config_management.visual.api_keys.edit_title') : t('config_management.visual.api_keys.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
|
||||
{t('config_management.visual.common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{editingApiKeyId !== null ? t('config_management.visual.common.update') : t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor={apiKeyInputId}>{t('config_management.visual.api_keys.input_label')}</label>
|
||||
<div className={styles.apiKeyModalInputRow}>
|
||||
<input
|
||||
id={apiKeyInputId}
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.api_keys.input_placeholder')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-describedby={formError ? `${apiKeyErrorId} ${apiKeyHintId}` : apiKeyHintId}
|
||||
aria-invalid={Boolean(formError)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<div id={apiKeyHintId} className="hint">{t('config_management.visual.api_keys.input_hint')}</div>
|
||||
{formError && <div id={apiKeyErrorId} className="error-box">{formError}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const StringListEditor = memo(function StringListEditor({
|
||||
value,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = value.length ? value : [];
|
||||
const [itemIds, setItemIds] = useState(() => items.map(() => makeClientId()));
|
||||
const renderItemIds = useMemo(() => {
|
||||
if (itemIds.length === items.length) return itemIds;
|
||||
if (itemIds.length > items.length) return itemIds.slice(0, items.length);
|
||||
return [...itemIds, ...Array.from({ length: items.length - itemIds.length }, () => makeClientId())];
|
||||
}, [itemIds, items.length]);
|
||||
|
||||
const updateItem = (index: number, nextValue: string) =>
|
||||
onChange(items.map((item, i) => (i === index ? nextValue : item)));
|
||||
const addItem = () => {
|
||||
setItemIds([...renderItemIds, makeClientId()]);
|
||||
onChange([...items, '']);
|
||||
};
|
||||
const removeItem = (index: number) => {
|
||||
setItemIds(renderItemIds.filter((_, i) => i !== index));
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const booleanValueOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'true', label: t('config_management.visual.payload_rules.boolean_true') },
|
||||
{ value: 'false', label: t('config_management.visual.payload_rules.boolean_false') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
const addParam = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: 'string',
|
||||
value: '',
|
||||
};
|
||||
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
|
||||
};
|
||||
|
||||
const removeParam = (ruleIndex: number, paramIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
|
||||
};
|
||||
|
||||
const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial<PayloadParamEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
|
||||
});
|
||||
};
|
||||
|
||||
const getValuePlaceholder = (valueType: PayloadParamValueType) => {
|
||||
switch (valueType) {
|
||||
case 'string':
|
||||
return t('config_management.visual.payload_rules.value_string');
|
||||
case 'number':
|
||||
return t('config_management.visual.payload_rules.value_number');
|
||||
case 'boolean':
|
||||
return t('config_management.visual.payload_rules.value_boolean');
|
||||
case 'json':
|
||||
return t('config_management.visual.payload_rules.value_json');
|
||||
default:
|
||||
return t('config_management.visual.payload_rules.value_default');
|
||||
}
|
||||
};
|
||||
|
||||
const getParamErrorMessage = (param: PayloadParamEntry) => {
|
||||
const errorCode = getPayloadParamValidationError(param);
|
||||
return getValidationMessage(t, errorCode);
|
||||
};
|
||||
|
||||
const renderParamValueEditor = (
|
||||
ruleIndex: number,
|
||||
paramIndex: number,
|
||||
param: PayloadParamEntry
|
||||
) => {
|
||||
if (param.valueType === 'boolean') {
|
||||
return (
|
||||
<Select
|
||||
value={param.value.toLowerCase() === 'true' || param.value.toLowerCase() === 'false' ? param.value.toLowerCase() : ''}
|
||||
options={booleanValueOptions}
|
||||
placeholder={t('config_management.visual.payload_rules.value_boolean')}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.valueType === 'json') {
|
||||
return (
|
||||
<textarea
|
||||
className={`input ${styles.payloadJsonInput}`}
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => {
|
||||
const paramError = getParamErrorMessage(param);
|
||||
|
||||
return (
|
||||
<div key={param.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
aria-label={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, {
|
||||
valueType: nextValue as PayloadParamValueType,
|
||||
value:
|
||||
nextValue === 'boolean'
|
||||
? 'true'
|
||||
: nextValue === 'json' && param.value.trim() === ''
|
||||
? '{}'
|
||||
: param.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{renderParamValueEditor(ruleIndex, paramIndex, param)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{paramError && <div className={`error-box ${styles.payloadParamError}`}>{paramError}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addParam(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_param')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadFilterRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadFilterRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.remove_params')}</div>
|
||||
<StringListEditor
|
||||
value={rule.params}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
inputAriaLabel={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
onChange={(params) => updateRule(ruleIndex, { params })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -7,20 +7,40 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
|
||||
export function Input({ label, hint, error, rightElement, className = '', id, ...rest }: InputProps) {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const describedBy = [rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
{label && <label>{label}</label>}
|
||||
{label && <label htmlFor={inputId}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input className={`input ${className}`.trim()} {...rest} />
|
||||
<input
|
||||
id={inputId}
|
||||
className={`input ${className}`.trim()}
|
||||
aria-invalid={Boolean(error) || rest['aria-invalid']}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
/>
|
||||
{rightElement && (
|
||||
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{hint && (
|
||||
<div id={hintId} className="hint">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id={errorId} className="error-box">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+109
-5
@@ -1,5 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconX } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -14,6 +23,14 @@ interface ModalProps {
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
let activeModalCount = 0;
|
||||
|
||||
const scrollLockSnapshot = {
|
||||
@@ -107,11 +124,23 @@ export function Modal({
|
||||
width = 520,
|
||||
className,
|
||||
closeDisabled = false,
|
||||
children
|
||||
children,
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const getFocusableElements = useCallback(() => {
|
||||
if (!modalRef.current) return [] as HTMLElement[];
|
||||
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(element) => !element.hasAttribute('disabled') && element.tabIndex !== -1
|
||||
);
|
||||
}, []);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
@@ -174,6 +203,70 @@ export function Modal({
|
||||
return () => unlockScroll();
|
||||
}, [shouldLockScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
previouslyFocusedRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const firstFocusable = getFocusableElements()[0];
|
||||
(firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [getFocusableElements, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || isVisible) return;
|
||||
previouslyFocusedRef.current?.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}, [isVisible, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (closeDisabled) return;
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
modalRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement || activeElement === modalRef.current) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeDisabled, getFocusableElements, handleClose, open]);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
@@ -181,18 +274,29 @@ export function Modal({
|
||||
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div className="modal-title" id={title ? titleId : undefined}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
|
||||
@@ -108,3 +108,7 @@
|
||||
background: rgba($primary-color, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.optionHighlighted {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
+112
-11
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
@@ -15,7 +15,10 @@ interface SelectProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
fullWidth?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -26,9 +29,16 @@ export function Select({
|
||||
className,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
fullWidth = true
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
fullWidth = true,
|
||||
id,
|
||||
}: SelectProps) {
|
||||
const generatedId = useId();
|
||||
const selectId = id ?? generatedId;
|
||||
const listboxId = `${selectId}-listbox`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,23 +51,113 @@ export function Select({
|
||||
}, [disabled, open]);
|
||||
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
|
||||
const resolvedHighlightedIndex =
|
||||
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
||||
const displayText = selected?.label ?? placeholder ?? '';
|
||||
const isPlaceholder = !selected && placeholder;
|
||||
|
||||
const commitSelection = useCallback(
|
||||
(nextIndex: number) => {
|
||||
const nextOption = options[nextIndex];
|
||||
if (!nextOption) return;
|
||||
onChange(nextOption.value);
|
||||
setOpen(false);
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const moveHighlight = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (options.length === 0) return;
|
||||
const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length;
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
return;
|
||||
case 'Home':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
return;
|
||||
case 'End':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(options.length - 1);
|
||||
return;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (resolvedHighlightedIndex >= 0) {
|
||||
commitSelection(resolvedHighlightedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'Escape':
|
||||
if (!isOpen) return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
case 'Tab':
|
||||
if (isOpen) setOpen(false);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
@@ -68,20 +168,21 @@ export function Select({
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt) => {
|
||||
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''}`}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
+229
-10
@@ -2,9 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
VisualConfigValues,
|
||||
VisualConfigValidationErrors,
|
||||
PayloadParamValidationErrorCode,
|
||||
} from '@/types/visualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
|
||||
|
||||
@@ -44,9 +47,100 @@ function parseApiKeysText(raw: unknown): string {
|
||||
return keys.join('\n');
|
||||
}
|
||||
|
||||
function replaceApiKeyValue(entry: unknown, apiKey: string): unknown {
|
||||
const record = asRecord(entry);
|
||||
if (!record) return apiKey;
|
||||
|
||||
if ('api-key' in record) return { ...record, 'api-key': apiKey };
|
||||
if ('apiKey' in record) return { ...record, apiKey };
|
||||
if ('key' in record) return { ...record, key: apiKey };
|
||||
if ('Key' in record) return { ...record, Key: apiKey };
|
||||
|
||||
return { ...record, 'api-key': apiKey };
|
||||
}
|
||||
|
||||
function buildApiKeyEntries(
|
||||
apiKeys: string[],
|
||||
metadata: ApiKeysStorageMetadata
|
||||
): Array<string | Record<string, unknown>> {
|
||||
return apiKeys.map((apiKey, index) => {
|
||||
const originalEntry = metadata.originalEntries[index];
|
||||
if (metadata.entryMode === 'object') {
|
||||
const replaced = replaceApiKeyValue(originalEntry, apiKey);
|
||||
return asRecord(replaced) ?? { 'api-key': apiKey };
|
||||
}
|
||||
|
||||
const record = asRecord(originalEntry);
|
||||
return record ? ({ ...record, ...(replaceApiKeyValue(record, apiKey) as Record<string, unknown>) }) : apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApiKeysStorage(parsed: Record<string, unknown>): {
|
||||
text: string;
|
||||
metadata: ApiKeysStorageMetadata;
|
||||
} {
|
||||
const legacyEntries = Array.isArray(parsed['api-keys']) ? parsed['api-keys'] : [];
|
||||
const auth = asRecord(parsed.auth);
|
||||
const providers = asRecord(auth?.providers);
|
||||
const configApiKeyProvider = asRecord(providers?.['config-api-key']);
|
||||
|
||||
if (configApiKeyProvider) {
|
||||
const providerEntries = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? configApiKeyProvider['api-key-entries']
|
||||
: Array.isArray(configApiKeyProvider['api-keys'])
|
||||
? configApiKeyProvider['api-keys']
|
||||
: [];
|
||||
const providerListKey = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? 'api-key-entries'
|
||||
: 'api-keys';
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(providerEntries),
|
||||
metadata: {
|
||||
source: 'auth-provider',
|
||||
providerListKey,
|
||||
entryMode:
|
||||
providerListKey === 'api-key-entries' || providerEntries.some((entry) => Boolean(asRecord(entry)))
|
||||
? 'object'
|
||||
: 'string',
|
||||
originalEntries: providerEntries,
|
||||
syncLegacy: legacyEntries.length > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(legacyEntries),
|
||||
metadata: {
|
||||
source: 'legacy',
|
||||
entryMode: legacyEntries.some((entry) => Boolean(asRecord(entry))) ? 'object' : 'string',
|
||||
originalEntries: legacyEntries,
|
||||
syncLegacy: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type YamlDocument = ReturnType<typeof parseDocument>;
|
||||
type YamlPath = string[];
|
||||
|
||||
type ApiKeysStorageMode = 'legacy' | 'auth-provider';
|
||||
type ApiKeysEntryMode = 'string' | 'object';
|
||||
|
||||
type ApiKeysStorageMetadata = {
|
||||
source: ApiKeysStorageMode;
|
||||
providerListKey?: 'api-keys' | 'api-key-entries';
|
||||
entryMode: ApiKeysEntryMode;
|
||||
originalEntries: unknown[];
|
||||
syncLegacy: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_API_KEYS_STORAGE_METADATA: ApiKeysStorageMetadata = {
|
||||
source: 'legacy',
|
||||
entryMode: 'string',
|
||||
originalEntries: [],
|
||||
syncLegacy: false,
|
||||
};
|
||||
|
||||
function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||
return doc.hasIn(path);
|
||||
}
|
||||
@@ -78,7 +172,11 @@ function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void
|
||||
doc.setIn(path, safe);
|
||||
return;
|
||||
}
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
// Preserve existing empty-string keys to avoid dropping template blocks/comments.
|
||||
// Only keep the key when it already exists in the YAML.
|
||||
if (docHas(doc, path)) {
|
||||
doc.setIn(path, '');
|
||||
}
|
||||
}
|
||||
|
||||
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
@@ -89,13 +187,81 @@ function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
doc.setIn(path, parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
function getNonNegativeIntegerError(value: string): 'non_negative_integer' | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!/^-?\d+$/.test(trimmed)) return 'non_negative_integer';
|
||||
return Number(trimmed) >= 0 ? undefined : 'non_negative_integer';
|
||||
}
|
||||
|
||||
function getPortError(value: string): 'port_range' | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!/^\d+$/.test(trimmed)) return 'port_range';
|
||||
const parsed = Number(trimmed);
|
||||
return parsed >= 1 && parsed <= 65535 ? undefined : 'port_range';
|
||||
}
|
||||
|
||||
export function getVisualConfigValidationErrors(
|
||||
values: VisualConfigValues
|
||||
): VisualConfigValidationErrors {
|
||||
return {
|
||||
port: getPortError(values.port),
|
||||
logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb),
|
||||
requestRetry: getNonNegativeIntegerError(values.requestRetry),
|
||||
maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval),
|
||||
'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds),
|
||||
'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries),
|
||||
'streaming.nonstreamKeepaliveInterval': getNonNegativeIntegerError(
|
||||
values.streaming.nonstreamKeepaliveInterval
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPayloadParamValidationError(
|
||||
param: PayloadParamEntry
|
||||
): PayloadParamValidationErrorCode | undefined {
|
||||
const trimmedValue = param.value.trim();
|
||||
|
||||
switch (param.valueType) {
|
||||
case 'number': {
|
||||
if (!trimmedValue) return 'payload_invalid_number';
|
||||
const parsed = Number(trimmedValue);
|
||||
return Number.isFinite(parsed) ? undefined : 'payload_invalid_number';
|
||||
}
|
||||
case 'boolean': {
|
||||
const normalized = trimmedValue.toLowerCase();
|
||||
return normalized === 'true' || normalized === 'false'
|
||||
? undefined
|
||||
: 'payload_invalid_boolean';
|
||||
}
|
||||
case 'json': {
|
||||
if (!trimmedValue) return 'payload_invalid_json';
|
||||
try {
|
||||
JSON.parse(param.value);
|
||||
return undefined;
|
||||
} catch {
|
||||
return 'payload_invalid_json';
|
||||
}
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
|
||||
return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param))));
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
@@ -272,6 +438,19 @@ export function useVisualConfig() {
|
||||
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
const [visualParseError, setVisualParseError] = useState<string | null>(null);
|
||||
const [apiKeysStorageMetadata, setApiKeysStorageMetadata] =
|
||||
useState<ApiKeysStorageMetadata>(DEFAULT_API_KEYS_STORAGE_METADATA);
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
);
|
||||
const visualHasPayloadValidationErrors = useMemo(
|
||||
() =>
|
||||
hasPayloadParamValidationErrors(visualValues.payloadDefaultRules) ||
|
||||
hasPayloadParamValidationErrors(visualValues.payloadOverrideRules),
|
||||
[visualValues.payloadDefaultRules, visualValues.payloadOverrideRules]
|
||||
);
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||
@@ -279,6 +458,11 @@ export function useVisualConfig() {
|
||||
|
||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const document = parseDocument(yamlContent);
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(document.errors[0]?.message ?? 'Invalid YAML');
|
||||
}
|
||||
|
||||
const parsedRaw: unknown = parseYaml(yamlContent) || {};
|
||||
const parsed = asRecord(parsedRaw) ?? {};
|
||||
const tls = asRecord(parsed.tls);
|
||||
@@ -287,6 +471,7 @@ export function useVisualConfig() {
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
const apiKeysStorage = resolveApiKeysStorage(parsed);
|
||||
|
||||
const newValues: VisualConfigValues = {
|
||||
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||
@@ -308,7 +493,7 @@ export function useVisualConfig() {
|
||||
: '',
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||
apiKeysText: apiKeysStorage.text,
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
@@ -343,9 +528,13 @@ export function useVisualConfig() {
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
} catch {
|
||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||
setApiKeysStorageMetadata(apiKeysStorage.metadata);
|
||||
setVisualParseError(null);
|
||||
return { ok: true as const };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid YAML';
|
||||
setVisualParseError(message);
|
||||
return { ok: false as const, error: message };
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -403,8 +592,35 @@ export function useVisualConfig() {
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
const apiKeyEntries = buildApiKeyEntries(apiKeys, apiKeysStorageMetadata);
|
||||
|
||||
if (apiKeysStorageMetadata.source === 'auth-provider') {
|
||||
ensureMapInDoc(doc, ['auth']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers', 'config-api-key']);
|
||||
|
||||
const providerListKey = apiKeysStorageMetadata.providerListKey ?? 'api-key-entries';
|
||||
const providerPath = ['auth', 'providers', 'config-api-key', providerListKey];
|
||||
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(providerPath, apiKeyEntries);
|
||||
} else if (docHas(doc, providerPath)) {
|
||||
doc.deleteIn(providerPath);
|
||||
}
|
||||
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers', 'config-api-key']);
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers']);
|
||||
deleteIfMapEmpty(doc, ['auth']);
|
||||
|
||||
if (apiKeysStorageMetadata.syncLegacy) {
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
}
|
||||
} else if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeyEntries);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
@@ -506,7 +722,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[baselineValues, visualValues]
|
||||
[apiKeysStorageMetadata, baselineValues, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
@@ -522,6 +738,9 @@ export function useVisualConfig() {
|
||||
return {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
visualParseError,
|
||||
visualValidationErrors,
|
||||
visualHasPayloadValidationErrors,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues,
|
||||
|
||||
@@ -1067,6 +1067,7 @@
|
||||
"title": "Config Panel",
|
||||
"editor_title": "Configuration File",
|
||||
"reload": "Reload",
|
||||
"reload_confirm_message": "Reloading will discard your unsaved changes. Do you want to continue?",
|
||||
"save": "Save",
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
@@ -1080,6 +1081,10 @@
|
||||
"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_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}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "Search config...",
|
||||
"search_button": "Search",
|
||||
@@ -1213,6 +1218,7 @@
|
||||
"json_path": "JSON Path (e.g., temperature)",
|
||||
"json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "Parameter Type",
|
||||
"param_value": "Parameter Value",
|
||||
"add_param": "Add Parameter",
|
||||
"no_rules": "No rules",
|
||||
"add_rule": "Add Rule",
|
||||
@@ -1231,7 +1237,17 @@
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
"value_json": "JSON value",
|
||||
"value_default": "Value"
|
||||
"value_default": "Value",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "Fix validation errors before saving",
|
||||
"port_range": "Enter a valid port between 1 and 65535",
|
||||
"non_negative_integer": "Enter a non-negative whole number",
|
||||
"payload_invalid_number": "Enter a valid number",
|
||||
"payload_invalid_boolean": "Choose true or false",
|
||||
"payload_invalid_json": "Enter valid JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "Edit",
|
||||
|
||||
@@ -1070,6 +1070,7 @@
|
||||
"title": "Панель конфигурации",
|
||||
"editor_title": "Файл конфигурации",
|
||||
"reload": "Перезагрузить",
|
||||
"reload_confirm_message": "Перезагрузка отбросит ваши несохранённые изменения. Продолжить?",
|
||||
"save": "Сохранить",
|
||||
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
|
||||
"status_idle": "Ожидание действия",
|
||||
@@ -1083,6 +1084,10 @@
|
||||
"status_save_failed": "Не удалось сохранить",
|
||||
"save_success": "Конфигурация успешно сохранена",
|
||||
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
|
||||
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "Поиск по конфигурации...",
|
||||
"search_button": "Поиск",
|
||||
@@ -1218,6 +1223,7 @@
|
||||
"json_path": "JSON Path (например, temperature)",
|
||||
"json_path_filter": "JSON Path (gjson/sjson), например generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "Тип параметра",
|
||||
"param_value": "Значение параметра",
|
||||
"add_param": "Добавить параметр",
|
||||
"no_rules": "Правил нет",
|
||||
"add_rule": "Добавить правило",
|
||||
@@ -1236,7 +1242,17 @@
|
||||
"value_number": "Числовое значение (например, 0.7)",
|
||||
"value_boolean": "true или false",
|
||||
"value_json": "Значение JSON",
|
||||
"value_default": "Значение"
|
||||
"value_default": "Значение",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "Исправьте ошибки валидации перед сохранением",
|
||||
"port_range": "Введите корректный порт от 1 до 65535",
|
||||
"non_negative_integer": "Введите неотрицательное целое число",
|
||||
"payload_invalid_number": "Введите корректное число",
|
||||
"payload_invalid_boolean": "Выберите true или false",
|
||||
"payload_invalid_json": "Введите корректный JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "Изменить",
|
||||
|
||||
@@ -1067,6 +1067,7 @@
|
||||
"title": "配置面板",
|
||||
"editor_title": "配置文件",
|
||||
"reload": "重新加载",
|
||||
"reload_confirm_message": "重新加载将丢弃你当前未保存的修改,确定继续吗?",
|
||||
"save": "保存",
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
@@ -1080,6 +1081,10 @@
|
||||
"status_save_failed": "保存失败",
|
||||
"save_success": "配置已保存",
|
||||
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
|
||||
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
|
||||
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
|
||||
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
|
||||
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "搜索配置内容...",
|
||||
"search_button": "搜索",
|
||||
@@ -1213,6 +1218,7 @@
|
||||
"json_path": "JSON 路径 (如 temperature)",
|
||||
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "参数类型",
|
||||
"param_value": "参数值",
|
||||
"add_param": "添加参数",
|
||||
"no_rules": "暂无规则",
|
||||
"add_rule": "添加规则",
|
||||
@@ -1231,7 +1237,17 @@
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
"value_json": "JSON 值",
|
||||
"value_default": "值"
|
||||
"value_default": "值",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "请先修复表单校验错误再保存",
|
||||
"port_range": "请输入 1 到 65535 之间的有效端口",
|
||||
"non_negative_integer": "请输入非负整数",
|
||||
"payload_invalid_number": "请输入有效数字",
|
||||
"payload_invalid_boolean": "请选择 true 或 false",
|
||||
"payload_invalid_json": "请输入有效的 JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "编辑",
|
||||
|
||||
+75
-29
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { animate } from 'motion/mini';
|
||||
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
@@ -21,7 +30,7 @@ import {
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
|
||||
import { AuthFileDetailModal } from '@/features/authFiles/components/AuthFileDetailModal';
|
||||
@@ -40,6 +49,11 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from './AuthFilesPage.module.scss';
|
||||
|
||||
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
|
||||
const easePower2In = (progress: number) => progress ** 3;
|
||||
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
|
||||
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
@@ -59,6 +73,7 @@ export function AuthFilesPage() {
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
|
||||
const previousSelectionCountRef = useRef(0);
|
||||
const selectionCountRef = useRef(0);
|
||||
|
||||
@@ -85,7 +100,7 @@ export function AuthFilesPage() {
|
||||
selectAllVisible,
|
||||
deselectAll,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
batchDelete,
|
||||
} = useAuthFilesData({ refreshKeyStats });
|
||||
|
||||
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
|
||||
@@ -104,7 +119,7 @@ export function AuthFilesPage() {
|
||||
handleDeleteLink,
|
||||
handleToggleFork,
|
||||
handleRenameAlias,
|
||||
handleDeleteAlias
|
||||
handleDeleteAlias,
|
||||
} = useAuthFilesOauth({ viewMode, files });
|
||||
|
||||
const {
|
||||
@@ -115,7 +130,7 @@ export function AuthFilesPage() {
|
||||
modelsFileType,
|
||||
modelsError,
|
||||
showModels,
|
||||
closeModelsModal
|
||||
closeModelsModal,
|
||||
} = useAuthFilesModels();
|
||||
|
||||
const {
|
||||
@@ -125,11 +140,11 @@ export function AuthFilesPage() {
|
||||
openPrefixProxyEditor,
|
||||
closePrefixProxyEditor,
|
||||
handlePrefixProxyChange,
|
||||
handlePrefixProxySave
|
||||
handlePrefixProxySave,
|
||||
} = useAuthFilesPrefixProxyEditor({
|
||||
disableControls: connectionStatus !== 'connected',
|
||||
loadFiles,
|
||||
loadKeyStats: refreshKeyStats
|
||||
loadKeyStats: refreshKeyStats,
|
||||
});
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
@@ -292,7 +307,7 @@ export function AuthFilesPage() {
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, {
|
||||
state: { fromAuthFiles: true }
|
||||
state: { fromAuthFiles: true },
|
||||
});
|
||||
},
|
||||
[filter, navigate]
|
||||
@@ -307,7 +322,7 @@ export function AuthFilesPage() {
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, {
|
||||
state: { fromAuthFiles: true }
|
||||
state: { fromAuthFiles: true },
|
||||
});
|
||||
},
|
||||
[filter, navigate]
|
||||
@@ -354,31 +369,55 @@ export function AuthFilesPage() {
|
||||
const actionsEl = floatingBatchActionsRef.current;
|
||||
if (!actionsEl) return;
|
||||
|
||||
gsap.killTweensOf(actionsEl);
|
||||
batchActionAnimationRef.current?.stop();
|
||||
batchActionAnimationRef.current = null;
|
||||
|
||||
if (currentCount > 0 && previousCount === 0) {
|
||||
gsap.fromTo(
|
||||
batchActionAnimationRef.current = animate(
|
||||
actionsEl,
|
||||
{ y: 56, autoAlpha: 0 },
|
||||
{ y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' }
|
||||
{
|
||||
transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM],
|
||||
opacity: [0, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.28,
|
||||
ease: easePower3Out,
|
||||
onComplete: () => {
|
||||
actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM;
|
||||
actionsEl.style.opacity = '1';
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (currentCount === 0 && previousCount > 0) {
|
||||
gsap.to(actionsEl, {
|
||||
y: 56,
|
||||
autoAlpha: 0,
|
||||
duration: 0.22,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
if (selectionCountRef.current === 0) {
|
||||
setBatchActionBarVisible(false);
|
||||
}
|
||||
batchActionAnimationRef.current = animate(
|
||||
actionsEl,
|
||||
{
|
||||
transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM],
|
||||
opacity: [1, 0],
|
||||
},
|
||||
{
|
||||
duration: 0.22,
|
||||
ease: easePower2In,
|
||||
onComplete: () => {
|
||||
if (selectionCountRef.current === 0) {
|
||||
setBatchActionBarVisible(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
previousSelectionCountRef.current = currentCount;
|
||||
}, [batchActionBarVisible, selectionCount]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
batchActionAnimationRef.current?.stop();
|
||||
batchActionAnimationRef.current = null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderFilterTags = () => (
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
@@ -395,7 +434,7 @@ export function AuthFilesPage() {
|
||||
style={{
|
||||
backgroundColor: isActive ? color.text : color.bg,
|
||||
color: isActive ? activeTextColor : color.text,
|
||||
borderColor: color.text
|
||||
borderColor: color.text,
|
||||
}}
|
||||
onClick={() => {
|
||||
setFilter(type);
|
||||
@@ -442,7 +481,9 @@ export function AuthFilesPage() {
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })}
|
||||
onClick={() =>
|
||||
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
|
||||
}
|
||||
disabled={disableControls || loading || deletingAll}
|
||||
loading={deletingAll}
|
||||
>
|
||||
@@ -502,9 +543,14 @@ export function AuthFilesPage() {
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : pageItems.length === 0 ? (
|
||||
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
|
||||
<EmptyState
|
||||
title={t('auth_files.search_empty_title')}
|
||||
description={t('auth_files.search_empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}>
|
||||
<div
|
||||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||||
>
|
||||
{pageItems.map((file) => (
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
@@ -543,7 +589,7 @@ export function AuthFilesPage() {
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filtered.length
|
||||
count: filtered.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
+118
-16
@@ -5,7 +5,7 @@ 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 { parse as parseYaml } from 'yaml';
|
||||
import { parse as parseYaml, parseDocument } from 'yaml';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -31,13 +31,17 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
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 {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
visualParseError,
|
||||
visualValidationErrors,
|
||||
visualHasPayloadValidationErrors,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues
|
||||
@@ -69,6 +73,10 @@ export function ConfigPage() {
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
const hasVisualModeError = !!visualParseError;
|
||||
const hasVisualValidationErrors =
|
||||
activeTab === 'visual' &&
|
||||
(Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -93,6 +101,17 @@ export function ConfigPage() {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'visual' || !visualParseError) return;
|
||||
|
||||
setActiveTab('source');
|
||||
localStorage.setItem('config-management:tab', 'source');
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: visualParseError }),
|
||||
'error'
|
||||
);
|
||||
}, [activeTab, showNotification, t, visualParseError]);
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -121,12 +140,44 @@ export function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (activeTab === 'visual' && visualParseError) {
|
||||
showNotification(t('config_management.visual_mode_save_blocked'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextMergedYaml = applyVisualChangesToYaml(content);
|
||||
const latestServerYaml = await configFileApi.fetchConfigYaml();
|
||||
|
||||
if (latestServerYaml === nextMergedYaml) {
|
||||
if (activeTab !== 'source') {
|
||||
const latestDocument = parseDocument(latestServerYaml);
|
||||
if (latestDocument.errors.length > 0) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_latest_yaml_invalid', {
|
||||
message: latestDocument.errors[0]?.message ?? t('config_management.visual_mode_save_blocked')
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// In source mode, save exactly what the user edited. In visual mode, materialize visual changes into the latest YAML.
|
||||
const nextMergedYaml =
|
||||
activeTab === 'source' ? content : applyVisualChangesToYaml(latestServerYaml);
|
||||
|
||||
// In visual mode, applyVisualChangesToYaml re-serializes YAML via parseDocument → toString,
|
||||
// which may reformat comments/whitespace. Normalize the server YAML through the same pipeline
|
||||
// so the diff only shows actual value changes, not cosmetic reformatting.
|
||||
let diffOriginal = latestServerYaml;
|
||||
if (activeTab !== 'source') {
|
||||
try {
|
||||
const doc = parseDocument(latestServerYaml);
|
||||
diffOriginal = doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
|
||||
} catch { /* keep raw on parse failure */ }
|
||||
}
|
||||
|
||||
if (diffOriginal === nextMergedYaml) {
|
||||
setDirty(false);
|
||||
setContent(latestServerYaml);
|
||||
setServerYaml(latestServerYaml);
|
||||
@@ -136,7 +187,7 @@ export function ConfigPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerYaml(latestServerYaml);
|
||||
setServerYaml(diffOriginal);
|
||||
setMergedYaml(nextMergedYaml);
|
||||
setDiffModalOpen(true);
|
||||
} catch (err: unknown) {
|
||||
@@ -156,18 +207,28 @@ export function ConfigPage() {
|
||||
if (tab === activeTab) return;
|
||||
|
||||
if (tab === 'source') {
|
||||
const nextContent = applyVisualChangesToYaml(content);
|
||||
if (nextContent !== content) {
|
||||
setContent(nextContent);
|
||||
setDirty(true);
|
||||
// Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments.
|
||||
if (visualDirty) {
|
||||
const nextContent = applyVisualChangesToYaml(content);
|
||||
if (nextContent !== content) {
|
||||
setContent(nextContent);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadVisualValuesFromYaml(content);
|
||||
const result = loadVisualValuesFromYaml(content);
|
||||
if (!result.ok) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: result.error }),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, showNotification, t, visualDirty]);
|
||||
|
||||
// Search functionality
|
||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||
@@ -331,20 +392,47 @@ export function ConfigPage() {
|
||||
if (disableControls) return t('config_management.status_disconnected');
|
||||
if (loading) return t('config_management.status_loading');
|
||||
if (error) return t('config_management.status_load_failed');
|
||||
if (hasVisualModeError) return t('config_management.visual_mode_unavailable');
|
||||
if (hasVisualValidationErrors) return t('config_management.visual.validation.validation_blocked');
|
||||
if (saving) return t('config_management.status_saving');
|
||||
if (isDirty) return t('config_management.status_dirty');
|
||||
return t('config_management.status_loaded');
|
||||
};
|
||||
|
||||
const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty;
|
||||
const isLoadedStatus =
|
||||
!disableControls &&
|
||||
!loading &&
|
||||
!error &&
|
||||
!saving &&
|
||||
!isDirty &&
|
||||
!hasVisualModeError &&
|
||||
!hasVisualValidationErrors;
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (error) return styles.error;
|
||||
if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error;
|
||||
if (isDirty) return styles.modified;
|
||||
if (!loading && !saving) return styles.saved;
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!isDirty) {
|
||||
void loadConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('config_management.reload_confirm_message'),
|
||||
confirmText: t('config_management.reload'),
|
||||
cancelText: t('common.cancel'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
await loadConfig();
|
||||
},
|
||||
});
|
||||
}, [isDirty, loadConfig, showConfirmation, t]);
|
||||
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
@@ -352,8 +440,8 @@ export function ConfigPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
onClick={handleReload}
|
||||
disabled={loading || saving}
|
||||
title={t('config_management.reload')}
|
||||
aria-label={t('config_management.reload')}
|
||||
>
|
||||
@@ -363,7 +451,15 @@ export function ConfigPage() {
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={handleSave}
|
||||
disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
|
||||
disabled={
|
||||
disableControls ||
|
||||
loading ||
|
||||
saving ||
|
||||
!isDirty ||
|
||||
diffModalOpen ||
|
||||
hasVisualModeError ||
|
||||
hasVisualValidationErrors
|
||||
}
|
||||
title={t('config_management.save')}
|
||||
aria-label={t('config_management.save')}
|
||||
>
|
||||
@@ -401,10 +497,16 @@ export function ConfigPage() {
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.content}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{!error && visualParseError && (
|
||||
<div className="error-box">
|
||||
{t('config_management.visual_mode_unavailable_detail', { message: visualParseError })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'visual' ? (
|
||||
<VisualConfigEditor
|
||||
values={visualValues}
|
||||
validationErrors={visualValidationErrors}
|
||||
disabled={disableControls || loading}
|
||||
onChange={setVisualValues}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json';
|
||||
export type PayloadParamValidationErrorCode =
|
||||
| 'payload_invalid_number'
|
||||
| 'payload_invalid_boolean'
|
||||
| 'payload_invalid_json';
|
||||
|
||||
export type VisualConfigFieldPath =
|
||||
| 'port'
|
||||
| 'logsMaxTotalSizeMb'
|
||||
| 'requestRetry'
|
||||
| 'maxRetryInterval'
|
||||
| 'streaming.keepaliveSeconds'
|
||||
| 'streaming.bootstrapRetries'
|
||||
| 'streaming.nonstreamKeepaliveInterval';
|
||||
|
||||
export type VisualConfigValidationErrorCode = 'port_range' | 'non_negative_integer';
|
||||
|
||||
export type VisualConfigValidationErrors = Partial<
|
||||
Record<VisualConfigFieldPath, VisualConfigValidationErrorCode>
|
||||
>;
|
||||
|
||||
export type PayloadParamEntry = {
|
||||
id: string;
|
||||
|
||||
+24
-37
@@ -184,9 +184,9 @@ export function buildAntigravityQuotaGroups(
|
||||
models: AntigravityModelsPayload
|
||||
): AntigravityQuotaGroup[] {
|
||||
const groups: AntigravityQuotaGroup[] = [];
|
||||
let geminiProResetTime: string | undefined;
|
||||
const [claudeDef, geminiProDef, flashDef, flashLiteDef, cuDef, geminiFlashDef, imageDef] =
|
||||
ANTIGRAVITY_QUOTA_GROUPS;
|
||||
const definitions = new Map(
|
||||
ANTIGRAVITY_QUOTA_GROUPS.map((definition) => [definition.id, definition] as const)
|
||||
);
|
||||
|
||||
const buildGroup = (
|
||||
def: AntigravityQuotaGroupDefinition,
|
||||
@@ -227,41 +227,28 @@ export function buildAntigravityQuotaGroups(
|
||||
};
|
||||
};
|
||||
|
||||
const claudeGroup = buildGroup(claudeDef);
|
||||
if (claudeGroup) {
|
||||
groups.push(claudeGroup);
|
||||
}
|
||||
const appendGroup = (
|
||||
id: string,
|
||||
overrideResetTime?: string
|
||||
): AntigravityQuotaGroup | null => {
|
||||
const definition = definitions.get(id);
|
||||
if (!definition) return null;
|
||||
const group = buildGroup(definition, overrideResetTime);
|
||||
if (group) {
|
||||
groups.push(group);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
const geminiProGroup = buildGroup(geminiProDef);
|
||||
if (geminiProGroup) {
|
||||
geminiProResetTime = geminiProGroup.resetTime;
|
||||
groups.push(geminiProGroup);
|
||||
}
|
||||
|
||||
const flashGroup = buildGroup(flashDef);
|
||||
if (flashGroup) {
|
||||
groups.push(flashGroup);
|
||||
}
|
||||
|
||||
const flashLiteGroup = buildGroup(flashLiteDef);
|
||||
if (flashLiteGroup) {
|
||||
groups.push(flashLiteGroup);
|
||||
}
|
||||
|
||||
const cuGroup = buildGroup(cuDef);
|
||||
if (cuGroup) {
|
||||
groups.push(cuGroup);
|
||||
}
|
||||
|
||||
const geminiFlashGroup = buildGroup(geminiFlashDef);
|
||||
if (geminiFlashGroup) {
|
||||
groups.push(geminiFlashGroup);
|
||||
}
|
||||
|
||||
const imageGroup = buildGroup(imageDef, geminiProResetTime);
|
||||
if (imageGroup) {
|
||||
groups.push(imageGroup);
|
||||
}
|
||||
appendGroup('claude-gpt');
|
||||
const gemini31ProGroup = appendGroup('gemini-3-1-pro-series');
|
||||
const geminiProGroup = appendGroup('gemini-3-pro');
|
||||
const geminiProResetTime = gemini31ProGroup?.resetTime ?? geminiProGroup?.resetTime;
|
||||
appendGroup('gemini-2-5-flash');
|
||||
appendGroup('gemini-2-5-flash-lite');
|
||||
appendGroup('gemini-2-5-cu');
|
||||
appendGroup('gemini-3-flash');
|
||||
appendGroup('gemini-image', geminiProResetTime);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -73,18 +73,18 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'claude-gpt',
|
||||
label: 'Claude/GPT',
|
||||
identifiers: [
|
||||
'claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'gpt-oss-120b-medium',
|
||||
],
|
||||
identifiers: ['claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro',
|
||||
label: 'Gemini 3 Pro',
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-1-pro-series',
|
||||
label: 'Gemini 3.1 Pro Series',
|
||||
identifiers: ['gemini-3.1-pro-high', 'gemini-3.1-pro-low'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
@@ -107,8 +107,8 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'gemini-image',
|
||||
label: 'gemini-3-pro-image',
|
||||
identifiers: ['gemini-3-pro-image'],
|
||||
label: 'gemini-3.1-flash-image',
|
||||
identifiers: ['gemini-3.1-flash-image'],
|
||||
labelFromModel: true,
|
||||
},
|
||||
];
|
||||
|
||||
+30
-10
@@ -104,20 +104,40 @@ export function parseIdTokenPayload(value: unknown): Record<string, unknown> | n
|
||||
}
|
||||
|
||||
export function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
const toRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parsed = toRecord(payload);
|
||||
if (!parsed) return null;
|
||||
|
||||
if ('models' in parsed) {
|
||||
return parsed;
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as Record<string, unknown>;
|
||||
|
||||
const nested = toRecord(parsed.body);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
|
||||
|
||||
Reference in New Issue
Block a user