Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -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>
);
});

View file

@ -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>
);
});

View file

@ -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;
}
}

View file

@ -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>
);
}

View file

@ -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>
);
});

View file

@ -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>
);
});

View file

@ -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>
);
});

View file

@ -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>
);
});

View 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;
}