refactor(config): extract visual editor blocks

This commit is contained in:
Supra4E8C
2026-03-07 14:50:07 +08:00
parent b00d647066
commit f37e6dac93
2 changed files with 779 additions and 758 deletions
+5 -758
View File
@@ -1,33 +1,22 @@
import { memo, useCallback, useId, 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,
PayloadParamValidationErrorCode,
PayloadParamValueType,
PayloadRule,
VisualConfigValidationErrorCode,
VisualConfigValidationErrors,
VisualConfigValues,
} 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';
ApiKeysCardEditor,
PayloadFilterRulesEditor,
PayloadRulesEditor,
} from './VisualConfigEditorBlocks';
interface VisualConfigEditorProps {
values: VisualConfigValues;
@@ -94,748 +83,6 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
}
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,
onChange,
}: {
value: string[];
disabled?: boolean;
placeholder?: 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}
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>
);
});
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)}
value={param.value}
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
disabled={disabled}
/>
);
}
return (
<input
className="input"
placeholder={getValuePlaceholder(param.valueType)}
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')}
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) => {
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')}
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>
);
});
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')}
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, validationErrors, disabled = false, onChange }: VisualConfigEditorProps) {
const { t } = useTranslation();
const routingStrategyLabelId = useId();
@@ -0,0 +1,774 @@
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,
onChange,
}: {
value: string[];
disabled?: boolean;
placeholder?: 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}
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)}
value={param.value}
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
disabled={disabled}
/>
);
}
return (
<input
className="input"
placeholder={getValuePlaceholder(param.valueType)}
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')}
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) => {
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')}
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')}
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>
);
});