Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,77 @@
|
|||
import { memo, useEffect, useMemo, useRef, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API_KEY_STRENGTH_SEGMENTS,
|
||||
evaluateApiKeyStrength,
|
||||
type ApiKeyStrengthTier,
|
||||
} from '@/utils/apiKeyStrength';
|
||||
import { segmentFillDelayMs } from './shared';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
// 三档语义色 + 段数承担第四档的区分:翡翠绿留给「活的流量」,此处用语义 success。
|
||||
const TIER_COLORS: Record<ApiKeyStrengthTier, string> = {
|
||||
weak: 'var(--error-color)',
|
||||
fair: 'var(--amber-color)',
|
||||
good: 'var(--success-color)',
|
||||
strong: 'var(--success-color)',
|
||||
};
|
||||
|
||||
const SEGMENT_INDEXES = Array.from({ length: API_KEY_STRENGTH_SEGMENTS }, (_, index) => index);
|
||||
|
||||
/**
|
||||
* 自拟 API Key 的强度参考条:四段依次点亮,只给档位标签,不参与保存校验。
|
||||
*/
|
||||
export const ApiKeyStrengthMeter = memo(function ApiKeyStrengthMeter({ value }: { value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { tier, segments } = useMemo(() => evaluateApiKeyStrength(value), [value]);
|
||||
|
||||
// 上一次的段数决定这次谁需要排队;渲染只读,提交后再推进
|
||||
const previousSegments = useRef(segments);
|
||||
const cascadeFrom = previousSegments.current;
|
||||
useEffect(() => {
|
||||
previousSegments.current = segments;
|
||||
}, [segments]);
|
||||
|
||||
const empty = segments === 0;
|
||||
const tierLabel = empty
|
||||
? t('config_management.visual.api_keys.strength.empty')
|
||||
: t(`config_management.visual.api_keys.strength.${tier}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.strengthMeter}
|
||||
style={
|
||||
{
|
||||
'--strength-color': empty ? 'var(--text-quaternary)' : TIER_COLORS[tier],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.strengthTrack}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={API_KEY_STRENGTH_SEGMENTS}
|
||||
aria-valuenow={segments}
|
||||
aria-valuetext={tierLabel}
|
||||
aria-label={t('config_management.visual.api_keys.strength.label')}
|
||||
>
|
||||
{SEGMENT_INDEXES.map((index) => (
|
||||
<span key={index} className={styles.strengthSegment}>
|
||||
<span
|
||||
className={styles.strengthSegmentFill}
|
||||
data-filled={index < segments}
|
||||
style={
|
||||
{
|
||||
'--segment-delay': `${segmentFillDelayMs(index, segments, cascadeFrom)}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className={styles.strengthLabel} aria-hidden="true">
|
||||
{empty ? '—' : tierLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
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 { useNotificationStore } from '@/stores';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import { generateSecureApiKey } from '@/utils/apiKey';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
import { ApiKeyStrengthMeter } from './ApiKeyStrengthMeter';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
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('');
|
||||
|
||||
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 className={styles.blockHeaderRow}>
|
||||
<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 className={styles.emptyState}>{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>
|
||||
<ApiKeyStrengthMeter value={inputValue} />
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
@use '../../../../styles/mixins' as *;
|
||||
|
||||
/* 区块编辑器(API 密钥 / 字符串列表 / 插件源认证 / 载荷规则)的共享样式。
|
||||
规则从旧 VisualConfigEditor.module.scss 收编,几何统一到 10px 圆角并引用动效 tokens。 */
|
||||
|
||||
/* ---------- 可展开输入 ---------- */
|
||||
|
||||
.expandableInputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expandableInputWrapper > .expandableTextarea,
|
||||
.expandableInputWrapper > :global(.input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.expandableTextarea {
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.expandableToggle {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.58;
|
||||
transition: opacity var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputExpanded .expandableToggle {
|
||||
top: 9px;
|
||||
right: 12px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ---------- 通用块布局 ---------- */
|
||||
|
||||
.blockHeaderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blockStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blockLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------- 规则卡 ---------- */
|
||||
|
||||
.ruleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 64%, transparent);
|
||||
}
|
||||
|
||||
.ruleCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ruleCardTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ---------- 字符串列表 ---------- */
|
||||
|
||||
.stringList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------- API 密钥弹窗 ---------- */
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
:global(.input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 强度参考条:四段承载档位,颜色承载严重度。
|
||||
段的填充走 scaleX(只碰 transform),键入时逐字重算也不掉帧。 */
|
||||
|
||||
.strengthMeter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.strengthTrack {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.strengthSegment {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: $radius-full;
|
||||
overflow: hidden;
|
||||
// 轨道 = 填充色的淡化步阶,未点亮的段也带着当前状态
|
||||
background: color-mix(in srgb, var(--strength-color) 16%, transparent);
|
||||
transition: background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.strengthSegmentFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--strength-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
// 段内比常规交互再快一档,把预算让给段间的 stagger
|
||||
transition:
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
// 延迟写在目标态上:点亮时依次排队,熄灭是系统响应,立刻发生
|
||||
&[data-filled='true'] {
|
||||
transform: scaleX(1);
|
||||
transition-delay: var(--segment-delay, 0ms);
|
||||
}
|
||||
|
||||
// 降级只去掉位移和排队,颜色变化保留(它才是状态本身)
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: background-color var(--dur-hover, 200ms) linear;
|
||||
|
||||
&[data-filled='true'] {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strengthLabel {
|
||||
min-height: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--strength-color);
|
||||
transition: color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
/* ---------- 插件源认证 ---------- */
|
||||
|
||||
.storeAuthEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.storeAuthEmpty {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.storeAuthRule {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 42%, transparent);
|
||||
}
|
||||
|
||||
.storeAuthRuleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.storeAuthGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.storeAuthField {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
> span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.storeAuthApplyTo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
> span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.storeAuthCheckboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.storeAuthCheckbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 28px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
|
||||
input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 载荷规则 ---------- */
|
||||
|
||||
.payloadRuleModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleModelRowProtocolFirst {
|
||||
grid-template-columns: 160px 1fr auto auto;
|
||||
}
|
||||
|
||||
.payloadModelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.payloadModelAdvanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-left: 10px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.payloadAdvancedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.payloadHeaderRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleParamRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.payloadRuleRawParamRow {
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(320px, 1fr) auto;
|
||||
}
|
||||
|
||||
.payloadRuleParamGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.payloadJsonInput {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
.payloadParamError {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
flex: 0 0 auto;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
.payloadHeaderRow,
|
||||
.payloadRuleParamRow,
|
||||
.payloadFilterModelRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.storeAuthRule,
|
||||
.ruleCard {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.blockHeaderRow,
|
||||
.ruleCardHeader {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.blockHeaderRow :global(.btn),
|
||||
.ruleCardHeader :global(.btn),
|
||||
.actionRow :global(.btn),
|
||||
.stringListRow :global(.btn) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.storeAuthRule,
|
||||
.ruleCard {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 无障碍 ---------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.expandableToggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
/** Minimum character count before the expand/collapse toggle appears. */
|
||||
const EXPAND_THRESHOLD = 30;
|
||||
|
||||
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
|
||||
export function ExpandableInput({
|
||||
value,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const autoResize = useCallback((el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// Strip newlines — these fields are single-line identifiers/paths that
|
||||
// would break YAML serialization if they contained line breaks.
|
||||
const sanitized = e.target.value.replace(/[\r\n]/g, '');
|
||||
onChange(sanitized);
|
||||
// autoResize is handled by useLayoutEffect after React syncs the
|
||||
// sanitized value back to the DOM — calling it here would measure
|
||||
// stale content.
|
||||
};
|
||||
|
||||
// Resize synchronously before paint to avoid visual flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (!collapsed && textareaRef.current) {
|
||||
autoResize(textareaRef.current);
|
||||
}
|
||||
}, [collapsed, value, autoResize]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={styles.expandableInputWrapper}>
|
||||
<input
|
||||
className={`input ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value.length > EXPAND_THRESHOLD && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}}
|
||||
title={t('common.expand')}
|
||||
aria-label={t('common.expand')}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('common.collapse')}
|
||||
aria-label={t('common.collapse')}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type { PayloadFilterRule, PayloadModelEntry } from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import { ExpandableInput } from './ExpandableInput';
|
||||
import { StringListEditor } from './StringListEditor';
|
||||
import { buildProtocolOptions } from './shared';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadFilterRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value;
|
||||
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, 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 className={styles.blockStack}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div key={rule.id} className={styles.ruleCard}>
|
||||
<div className={styles.ruleCardHeader}>
|
||||
<div className={styles.ruleCardTitle}>
|
||||
{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 className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.models')}
|
||||
</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
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 className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addModel(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{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 className={styles.emptyState}>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,778 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type {
|
||||
PayloadHeaderEntry,
|
||||
PayloadModelEntry,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import {
|
||||
getPayloadParamValidationError,
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
|
||||
} from '@/hooks/useVisualConfig';
|
||||
import { FieldShell } from '../fields/FieldPrimitives';
|
||||
import { ExpandableInput } from './ExpandableInput';
|
||||
import { StringListEditor } from './StringListEditor';
|
||||
import { buildProtocolOptions, getValidationMessage } from './shared';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
function hasPayloadModelAdvancedSettings(model: PayloadModelEntry) {
|
||||
return Boolean(
|
||||
model.fromProtocol ||
|
||||
(model.headers?.length ?? 0) > 0 ||
|
||||
(model.match?.length ?? 0) > 0 ||
|
||||
(model.notMatch?.length ?? 0) > 0 ||
|
||||
(model.exist?.length ?? 0) > 0 ||
|
||||
(model.notExist?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
rawJsonValues = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
rawJsonValues?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value;
|
||||
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
|
||||
const fromProtocolOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: '',
|
||||
label: t('config_management.visual.payload_rules.provider_default'),
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
label: t('config_management.visual.payload_rules.provider_openai'),
|
||||
},
|
||||
{
|
||||
value: 'responses',
|
||||
label: t('config_management.visual.payload_rules.provider_responses'),
|
||||
},
|
||||
{
|
||||
value: 'gemini',
|
||||
label: t('config_management.visual.payload_rules.provider_gemini'),
|
||||
},
|
||||
{
|
||||
value: 'claude',
|
||||
label: t('config_management.visual.payload_rules.provider_claude'),
|
||||
},
|
||||
],
|
||||
[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 [modelAdvancedOverrides, setModelAdvancedOverrides] = useState<Record<string, boolean>>({});
|
||||
|
||||
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 toggleModelAdvanced = (modelId: string, defaultExpanded: boolean) => {
|
||||
setModelAdvancedOverrides((current) => ({
|
||||
...current,
|
||||
[modelId]: !(current[modelId] ?? defaultExpanded),
|
||||
}));
|
||||
};
|
||||
|
||||
const addHeader = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const model = rule.models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
headers: [...(model.headers ?? []), { id: makeClientId(), name: '', value: '' }],
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeader = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
headerIndex: number,
|
||||
patch: Partial<PayloadHeaderEntry>
|
||||
) => {
|
||||
const model = rules[ruleIndex].models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
headers: (model.headers ?? []).map((header, i) =>
|
||||
i === headerIndex ? { ...header, ...patch } : header
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const removeHeader = (ruleIndex: number, modelIndex: number, headerIndex: number) => {
|
||||
const model = rules[ruleIndex].models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
headers: (model.headers ?? []).filter((_, i) => i !== headerIndex),
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = (ruleIndex: number, modelIndex: number, key: 'match' | 'notMatch') => {
|
||||
const model = rules[ruleIndex].models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
[key]: [
|
||||
...(model[key] ?? []),
|
||||
{ id: makeClientId(), path: '', valueType: 'string', value: '' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const updateCondition = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
key: 'match' | 'notMatch',
|
||||
conditionIndex: number,
|
||||
patch: Partial<PayloadParamEntry>
|
||||
) => {
|
||||
const model = rules[ruleIndex].models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
[key]: (model[key] ?? []).map((condition, i) =>
|
||||
i === conditionIndex ? { ...condition, ...patch } : condition
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const removeCondition = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
key: 'match' | 'notMatch',
|
||||
conditionIndex: number
|
||||
) => {
|
||||
const model = rules[ruleIndex].models[modelIndex];
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
[key]: (model[key] ?? []).filter((_, i) => i !== conditionIndex),
|
||||
});
|
||||
};
|
||||
|
||||
const addParam = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: rawJsonValues ? 'json' : '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(
|
||||
rawJsonValues ? { ...param, valueType: 'json' } : param
|
||||
);
|
||||
return getValidationMessage(t, errorCode);
|
||||
};
|
||||
|
||||
const renderConditionValueEditor = (
|
||||
ruleIndex: number,
|
||||
modelIndex: number,
|
||||
key: 'match' | 'notMatch',
|
||||
conditionIndex: number,
|
||||
condition: PayloadParamEntry
|
||||
) => {
|
||||
if (condition.valueType === 'boolean') {
|
||||
return (
|
||||
<Select
|
||||
value={
|
||||
condition.value.toLowerCase() === 'true' || condition.value.toLowerCase() === 'false'
|
||||
? condition.value.toLowerCase()
|
||||
: ''
|
||||
}
|
||||
options={booleanValueOptions}
|
||||
placeholder={t('config_management.visual.payload_rules.value_boolean')}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.condition_value')}
|
||||
onChange={(nextValue) =>
|
||||
updateCondition(ruleIndex, modelIndex, key, conditionIndex, { value: nextValue })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (condition.valueType === 'json') {
|
||||
return (
|
||||
<textarea
|
||||
className={`input ${styles.payloadJsonInput}`}
|
||||
placeholder={getValuePlaceholder(condition.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.condition_value')}
|
||||
value={condition.value}
|
||||
onChange={(e) =>
|
||||
updateCondition(ruleIndex, modelIndex, key, conditionIndex, {
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandableInput
|
||||
placeholder={getValuePlaceholder(condition.valueType)}
|
||||
ariaLabel={t('config_management.visual.payload_rules.condition_value')}
|
||||
value={condition.value}
|
||||
onChange={(nextValue) =>
|
||||
updateCondition(ruleIndex, modelIndex, key, conditionIndex, { value: nextValue })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderParamValueEditor = (
|
||||
ruleIndex: number,
|
||||
paramIndex: number,
|
||||
param: PayloadParamEntry
|
||||
) => {
|
||||
if (rawJsonValues) {
|
||||
return (
|
||||
<textarea
|
||||
className={`input ${styles.payloadJsonInput}`}
|
||||
placeholder={t('config_management.visual.payload_rules.value_raw_json')}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) =>
|
||||
updateParam(ruleIndex, paramIndex, { value: e.target.value, valueType: 'json' })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ExpandableInput
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.blockStack}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div key={rule.id} className={styles.ruleCard}>
|
||||
<div className={styles.ruleCardHeader}>
|
||||
<div className={styles.ruleCardTitle}>
|
||||
{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 className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.models')}
|
||||
</div>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => {
|
||||
const hasAdvancedSettings = hasPayloadModelAdvancedSettings(model);
|
||||
const advancedExpanded = modelAdvancedOverrides[model.id] ?? hasAdvancedSettings;
|
||||
|
||||
return (
|
||||
<div key={model.id} className={styles.payloadModelGroup}>
|
||||
<div
|
||||
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'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, { name: nextValue })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, { name: nextValue })
|
||||
}
|
||||
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="secondary"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => toggleModelAdvanced(model.id, hasAdvancedSettings)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{advancedExpanded
|
||||
? t('config_management.visual.payload_rules.hide_advanced')
|
||||
: t('config_management.visual.payload_rules.advanced')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{advancedExpanded ? (
|
||||
<div className={styles.payloadModelAdvanced}>
|
||||
<div className={styles.payloadAdvancedGrid}>
|
||||
<FieldShell
|
||||
label={t('config_management.visual.payload_rules.from_protocol')}
|
||||
>
|
||||
<Select
|
||||
value={model.fromProtocol ?? ''}
|
||||
options={fromProtocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.from_protocol')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
fromProtocol: (nextValue ||
|
||||
undefined) as PayloadModelEntry['fromProtocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldShell>
|
||||
</div>
|
||||
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.headers')}
|
||||
</div>
|
||||
{(model.headers ?? []).map((header, headerIndex) => (
|
||||
<div key={header.id} className={styles.payloadHeaderRow}>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.header_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.header_name')}
|
||||
value={header.name}
|
||||
onChange={(nextValue) =>
|
||||
updateHeader(ruleIndex, modelIndex, headerIndex, {
|
||||
name: nextValue,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.header_value')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.header_value')}
|
||||
value={header.value}
|
||||
onChange={(nextValue) =>
|
||||
updateHeader(ruleIndex, modelIndex, headerIndex, {
|
||||
value: nextValue,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeHeader(ruleIndex, modelIndex, headerIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addHeader(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_header')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(['match', 'notMatch'] as const).map((conditionKey) => (
|
||||
<div key={conditionKey} className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t(`config_management.visual.payload_rules.${conditionKey}`)}
|
||||
</div>
|
||||
{(model[conditionKey] ?? []).map((condition, conditionIndex) => {
|
||||
const conditionError = getValidationMessage(
|
||||
t,
|
||||
getPayloadParamValidationError(condition)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={condition.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<ExpandableInput
|
||||
placeholder={t(
|
||||
'config_management.visual.payload_rules.condition_path'
|
||||
)}
|
||||
ariaLabel={t(
|
||||
'config_management.visual.payload_rules.condition_path'
|
||||
)}
|
||||
value={condition.path}
|
||||
onChange={(nextValue) =>
|
||||
updateCondition(
|
||||
ruleIndex,
|
||||
modelIndex,
|
||||
conditionKey,
|
||||
conditionIndex,
|
||||
{ path: nextValue }
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={condition.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t(
|
||||
'config_management.visual.payload_rules.param_type'
|
||||
)}
|
||||
onChange={(nextValue) =>
|
||||
updateCondition(
|
||||
ruleIndex,
|
||||
modelIndex,
|
||||
conditionKey,
|
||||
conditionIndex,
|
||||
{
|
||||
valueType: nextValue as PayloadParamValueType,
|
||||
value:
|
||||
nextValue === 'boolean'
|
||||
? 'true'
|
||||
: nextValue === 'json' &&
|
||||
condition.value.trim() === ''
|
||||
? '{}'
|
||||
: condition.value,
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
{renderConditionValueEditor(
|
||||
ruleIndex,
|
||||
modelIndex,
|
||||
conditionKey,
|
||||
conditionIndex,
|
||||
condition
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() =>
|
||||
removeCondition(
|
||||
ruleIndex,
|
||||
modelIndex,
|
||||
conditionKey,
|
||||
conditionIndex
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{conditionError ? (
|
||||
<div className={`error-box ${styles.payloadParamError}`}>
|
||||
{conditionError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addCondition(ruleIndex, modelIndex, conditionKey)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_condition')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={styles.payloadAdvancedGrid}>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.exist')}
|
||||
</div>
|
||||
<StringListEditor
|
||||
value={model.exist ?? []}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.condition_path')}
|
||||
inputAriaLabel={t(
|
||||
'config_management.visual.payload_rules.condition_path'
|
||||
)}
|
||||
onChange={(exist) => updateModel(ruleIndex, modelIndex, { exist })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{t('config_management.visual.payload_rules.notExist')}
|
||||
</div>
|
||||
<StringListEditor
|
||||
value={model.notExist ?? []}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.condition_path')}
|
||||
inputAriaLabel={t(
|
||||
'config_management.visual.payload_rules.condition_path'
|
||||
)}
|
||||
onChange={(notExist) =>
|
||||
updateModel(ruleIndex, modelIndex, { notExist })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => addModel(ruleIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.blockStack}>
|
||||
<div className={styles.blockLabel}>
|
||||
{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,
|
||||
rawJsonValues ? styles.payloadRuleRawParamRow : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, { path: nextValue })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{rawJsonValues ? null : (
|
||||
<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 className={styles.actionRow}>
|
||||
<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 className={styles.emptyState}>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type {
|
||||
PluginStoreAuthApplyTo,
|
||||
PluginStoreAuthRule,
|
||||
PluginStoreAuthType,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import { ExpandableInput } from './ExpandableInput';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
const PLUGIN_STORE_AUTH_TYPE_OPTIONS: Array<{ value: PluginStoreAuthType; labelKey: string }> = [
|
||||
{ value: 'bearer', labelKey: 'config_management.visual.sections.system.store_auth_type_bearer' },
|
||||
{
|
||||
value: 'github-token',
|
||||
labelKey: 'config_management.visual.sections.system.store_auth_type_github_token',
|
||||
},
|
||||
{ value: 'basic', labelKey: 'config_management.visual.sections.system.store_auth_type_basic' },
|
||||
{ value: 'header', labelKey: 'config_management.visual.sections.system.store_auth_type_header' },
|
||||
{ value: 'none', labelKey: 'config_management.visual.sections.system.store_auth_type_none' },
|
||||
];
|
||||
|
||||
const PLUGIN_STORE_AUTH_APPLY_TO_OPTIONS: Array<{
|
||||
value: PluginStoreAuthApplyTo;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'registry',
|
||||
labelKey: 'config_management.visual.sections.system.store_auth_apply_registry',
|
||||
},
|
||||
{
|
||||
value: 'metadata',
|
||||
labelKey: 'config_management.visual.sections.system.store_auth_apply_metadata',
|
||||
},
|
||||
{
|
||||
value: 'artifact',
|
||||
labelKey: 'config_management.visual.sections.system.store_auth_apply_artifact',
|
||||
},
|
||||
];
|
||||
|
||||
const createPluginStoreAuthRule = (): PluginStoreAuthRule => ({
|
||||
id: makeClientId(),
|
||||
match: '',
|
||||
applyTo: [],
|
||||
type: 'bearer',
|
||||
tokenEnv: '',
|
||||
usernameEnv: '',
|
||||
passwordEnv: '',
|
||||
headerName: '',
|
||||
headerValueEnv: '',
|
||||
allowInsecure: false,
|
||||
});
|
||||
|
||||
export const PluginStoreAuthEditor = memo(function PluginStoreAuthEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PluginStoreAuthRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PluginStoreAuthRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateRule = (id: string, patch: Partial<PluginStoreAuthRule>) => {
|
||||
onChange(value.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)));
|
||||
};
|
||||
const addRule = () => onChange([...value, createPluginStoreAuthRule()]);
|
||||
const removeRule = (id: string) => onChange(value.filter((rule) => rule.id !== id));
|
||||
const toggleApplyTo = (rule: PluginStoreAuthRule, kind: PluginStoreAuthApplyTo) => {
|
||||
const nextApplyTo = rule.applyTo.includes(kind)
|
||||
? rule.applyTo.filter((item) => item !== kind)
|
||||
: [...rule.applyTo, kind];
|
||||
updateRule(rule.id, { applyTo: nextApplyTo });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.storeAuthEditor}>
|
||||
{value.length === 0 ? (
|
||||
<p className={styles.storeAuthEmpty}>
|
||||
{t('config_management.visual.sections.system.store_auth_empty')}
|
||||
</p>
|
||||
) : null}
|
||||
{value.map((rule) => {
|
||||
const usesToken = rule.type === 'bearer' || rule.type === 'github-token';
|
||||
const usesBasic = rule.type === 'basic';
|
||||
const usesHeader = rule.type === 'header';
|
||||
return (
|
||||
<div key={rule.id} className={styles.storeAuthRule}>
|
||||
<div className={styles.storeAuthRuleHeader}>
|
||||
<strong>
|
||||
{rule.match || t('config_management.visual.sections.system.store_auth_rule')}
|
||||
</strong>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeRule(rule.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.storeAuthGrid}>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>{t('config_management.visual.sections.system.store_auth_match')}</span>
|
||||
<ExpandableInput
|
||||
value={rule.match}
|
||||
placeholder="https://api.github.com/repos/owner/repo/releases/"
|
||||
disabled={disabled}
|
||||
onChange={(match) => updateRule(rule.id, { match })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>{t('config_management.visual.sections.system.store_auth_type')}</span>
|
||||
<Select
|
||||
value={rule.type}
|
||||
options={PLUGIN_STORE_AUTH_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
disabled={disabled}
|
||||
onChange={(type) => updateRule(rule.id, { type: type as PluginStoreAuthType })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.storeAuthApplyTo}>
|
||||
<span>{t('config_management.visual.sections.system.store_auth_apply_to')}</span>
|
||||
<div className={styles.storeAuthCheckboxes}>
|
||||
{PLUGIN_STORE_AUTH_APPLY_TO_OPTIONS.map((option) => (
|
||||
<label key={option.value} className={styles.storeAuthCheckbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.applyTo.includes(option.value)}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleApplyTo(rule, option.value)}
|
||||
/>
|
||||
<span>{t(option.labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small>
|
||||
{t('config_management.visual.sections.system.store_auth_apply_to_hint')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{usesToken ? (
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>{t('config_management.visual.sections.system.store_auth_token_env')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={rule.tokenEnv}
|
||||
placeholder="CLIPROXY_PLUGIN_STORE_TOKEN"
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateRule(rule.id, { tokenEnv: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{usesBasic ? (
|
||||
<div className={styles.storeAuthGrid}>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>
|
||||
{t('config_management.visual.sections.system.store_auth_username_env')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={rule.usernameEnv}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateRule(rule.id, { usernameEnv: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>
|
||||
{t('config_management.visual.sections.system.store_auth_password_env')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={rule.passwordEnv}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateRule(rule.id, { passwordEnv: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{usesHeader ? (
|
||||
<div className={styles.storeAuthGrid}>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>
|
||||
{t('config_management.visual.sections.system.store_auth_header_name')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={rule.headerName}
|
||||
placeholder="X-Plugin-Token"
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateRule(rule.id, { headerName: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.storeAuthField}>
|
||||
<span>
|
||||
{t('config_management.visual.sections.system.store_auth_header_value_env')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={rule.headerValueEnv}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
updateRule(rule.id, { headerValueEnv: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className={styles.storeAuthCheckbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.allowInsecure}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateRule(rule.id, { allowInsecure: event.target.checked })}
|
||||
/>
|
||||
<span>{t('config_management.visual.sections.system.store_auth_allow_insecure')}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.sections.system.store_auth_add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import { ExpandableInput } from './ExpandableInput';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
export 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 className={styles.stringList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
|
||||
<ExpandableInput
|
||||
placeholder={placeholder}
|
||||
ariaLabel={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(nextValue) => updateItem(index, nextValue)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.actionRow}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
59
frontend/src/features/config/components/blocks/shared.ts
Normal file
59
frontend/src/features/config/components/blocks/shared.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// 区块编辑器共享的纯工具(载荷/规则部分从旧 VisualConfigEditorBlocks 原样迁出,
|
||||
// 独立成文件以规避组件文件导出非组件的 react-refresh 限制)。
|
||||
|
||||
import type { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
PayloadModelEntry,
|
||||
PayloadParamValidationErrorCode,
|
||||
VisualConfigValidationErrorCode,
|
||||
} from '@/types/visualConfig';
|
||||
import { VISUAL_CONFIG_PROTOCOL_OPTIONS } from '@/hooks/useVisualConfig';
|
||||
|
||||
export function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: VisualConfigValidationErrorCode | PayloadParamValidationErrorCode
|
||||
) {
|
||||
if (!errorCode) return undefined;
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
export function buildProtocolOptions(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
rules: Array<{ models: PayloadModelEntry[] }>
|
||||
) {
|
||||
const options: Array<{ value: string; label: string }> = VISUAL_CONFIG_PROTOCOL_OPTIONS.map(
|
||||
(option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})
|
||||
);
|
||||
const seen = new Set<string>(options.map((option) => option.value));
|
||||
|
||||
for (const rule of rules) {
|
||||
for (const model of rule.models) {
|
||||
const protocol = model.protocol;
|
||||
if (!protocol || !protocol.trim() || seen.has(protocol)) continue;
|
||||
seen.add(protocol);
|
||||
options.push({ value: protocol, label: protocol });
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/** API Key 强度条相邻两段点亮的间隔;4 段全亮 = 135ms + 段内 160ms,整组仍在 300ms 内 */
|
||||
export const SEGMENT_STAGGER_MS = 45;
|
||||
|
||||
/**
|
||||
* 强度条段填充的起跑延迟:只有本次新增的段排队,已亮的段和熄灭都不延迟。
|
||||
* 因此「生成」一次点亮四段是依次的波,而键入让强度 +1 段是即时的。
|
||||
*/
|
||||
export function segmentFillDelayMs(
|
||||
index: number,
|
||||
segments: number,
|
||||
previousSegments: number
|
||||
): number {
|
||||
const filled = index < segments;
|
||||
if (!filled || index < previousSegments) return 0;
|
||||
return (index - Math.max(previousSegments, 0)) * SEGMENT_STAGGER_MS;
|
||||
}
|
||||
Loading…
Reference in a new issue