Files
new-api-oiss/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx

3164 lines
125 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
Collapse,
Input,
Modal,
Row,
Select,
Space,
Switch,
Tag,
TextArea,
Typography,
} from '@douyinfe/semi-ui';
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
const { Text } = Typography;
const OPERATION_MODE_OPTIONS = [
{ label: '设置字段', value: 'set' },
{ label: '删除字段', value: 'delete' },
{ label: '追加到末尾', value: 'append' },
{ label: '追加到开头', value: 'prepend' },
{ label: '复制字段', value: 'copy' },
{ label: '移动字段', value: 'move' },
{ label: '字符串替换', value: 'replace' },
{ label: '正则替换', value: 'regex_replace' },
{ label: '裁剪前缀', value: 'trim_prefix' },
{ label: '裁剪后缀', value: 'trim_suffix' },
{ label: '确保前缀', value: 'ensure_prefix' },
{ label: '确保后缀', value: 'ensure_suffix' },
{ label: '去掉空白', value: 'trim_space' },
{ label: '转小写', value: 'to_lower' },
{ label: '转大写', value: 'to_upper' },
{ label: '返回自定义错误', value: 'return_error' },
{ label: '清理对象项', value: 'prune_objects' },
{ label: '请求头透传', value: 'pass_headers' },
{ label: '字段同步', value: 'sync_fields' },
{ label: '设置请求头', value: 'set_header' },
{ label: '删除请求头', value: 'delete_header' },
{ label: '复制请求头', value: 'copy_header' },
{ label: '移动请求头', value: 'move_header' },
];
const OPERATION_MODE_VALUES = new Set(
OPERATION_MODE_OPTIONS.map((item) => item.value),
);
const CONDITION_MODE_OPTIONS = [
{ label: '完全匹配', value: 'full' },
{ label: '前缀匹配', value: 'prefix' },
{ label: '后缀匹配', value: 'suffix' },
{ label: '包含', value: 'contains' },
{ label: '大于', value: 'gt' },
{ label: '大于等于', value: 'gte' },
{ label: '小于', value: 'lt' },
{ label: '小于等于', value: 'lte' },
];
const CONDITION_MODE_VALUES = new Set(
CONDITION_MODE_OPTIONS.map((item) => item.value),
);
const MODE_META = {
delete: { path: true },
set: { path: true, value: true, keepOrigin: true },
append: { path: true, value: true, keepOrigin: true },
prepend: { path: true, value: true, keepOrigin: true },
copy: { from: true, to: true },
move: { from: true, to: true },
replace: { path: true, from: true, to: false },
regex_replace: { path: true, from: true, to: false },
trim_prefix: { path: true, value: true },
trim_suffix: { path: true, value: true },
ensure_prefix: { path: true, value: true },
ensure_suffix: { path: true, value: true },
trim_space: { path: true },
to_lower: { path: true },
to_upper: { path: true },
return_error: { value: true },
prune_objects: { pathOptional: true, value: true },
pass_headers: { value: true, keepOrigin: true },
sync_fields: { from: true, to: true },
set_header: { path: true, value: true, keepOrigin: true },
delete_header: { path: true },
copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true },
move_header: { from: true, to: true, keepOrigin: true, pathAlias: true },
};
const VALUE_REQUIRED_MODES = new Set([
'trim_prefix',
'trim_suffix',
'ensure_prefix',
'ensure_suffix',
'set_header',
'return_error',
'prune_objects',
'pass_headers',
]);
const FROM_REQUIRED_MODES = new Set([
'copy',
'move',
'replace',
'regex_replace',
'copy_header',
'move_header',
'sync_fields',
]);
const TO_REQUIRED_MODES = new Set([
'copy',
'move',
'copy_header',
'move_header',
'sync_fields',
]);
const MODE_DESCRIPTIONS = {
set: '把值写入目标字段',
delete: '删除目标字段',
append: '把值追加到数组 / 字符串 / 对象末尾',
prepend: '把值追加到数组 / 字符串 / 对象开头',
copy: '把来源字段复制到目标字段',
move: '把来源字段移动到目标字段',
replace: '在目标字段里做字符串替换',
regex_replace: '在目标字段里做正则替换',
trim_prefix: '去掉字符串前缀',
trim_suffix: '去掉字符串后缀',
ensure_prefix: '确保字符串有指定前缀',
ensure_suffix: '确保字符串有指定后缀',
trim_space: '去掉字符串头尾空白',
to_lower: '把字符串转成小写',
to_upper: '把字符串转成大写',
return_error: '立即返回自定义错误',
prune_objects: '按条件清理对象中的子项',
pass_headers: '把指定请求头透传到上游请求',
sync_fields: '在一个字段有值、另一个缺失时自动补齐',
set_header: '设置运行期请求头',
delete_header: '删除运行期请求头',
copy_header: '复制请求头',
move_header: '移动请求头',
};
const getModePathLabel = (mode) => {
if (mode === 'set_header' || mode === 'delete_header') {
return '请求头名称';
}
if (mode === 'prune_objects') {
return '目标路径(可选)';
}
return '目标字段路径';
};
const getModePathPlaceholder = (mode) => {
if (mode === 'set_header') return 'Authorization';
if (mode === 'delete_header') return 'X-Debug-Mode';
if (mode === 'prune_objects') return 'messages';
return 'temperature';
};
const getModeFromLabel = (mode) => {
if (mode === 'replace') return '匹配文本';
if (mode === 'regex_replace') return '正则表达式';
if (mode === 'copy_header' || mode === 'move_header') return '来源请求头';
return '来源字段';
};
const getModeFromPlaceholder = (mode) => {
if (mode === 'replace') return 'openai/';
if (mode === 'regex_replace') return '^gpt-';
if (mode === 'copy_header' || mode === 'move_header') return 'Authorization';
return 'model';
};
const getModeToLabel = (mode) => {
if (mode === 'replace' || mode === 'regex_replace') return '替换为';
if (mode === 'copy_header' || mode === 'move_header') return '目标请求头';
return '目标字段';
};
const getModeToPlaceholder = (mode) => {
if (mode === 'replace') return '(可留空)';
if (mode === 'regex_replace') return 'openai/gpt-';
if (mode === 'copy_header' || mode === 'move_header') return 'X-Upstream-Auth';
return 'original_model';
};
const getModeValueLabel = (mode) => {
if (mode === 'set_header') return '请求头值';
if (mode === 'pass_headers') return '透传请求头(支持逗号分隔或 JSON 数组)';
if (
mode === 'trim_prefix' ||
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
) {
return '前后缀文本';
}
if (mode === 'prune_objects') {
return '清理规则(字符串或 JSON 对象)';
}
return '值(支持 JSON 或普通文本)';
};
const getModeValuePlaceholder = (mode) => {
if (mode === 'set_header') return 'Bearer sk-xxx';
if (mode === 'pass_headers') return 'Authorization, X-Request-Id';
if (
mode === 'trim_prefix' ||
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
) {
return 'openai/';
}
if (mode === 'prune_objects') {
return '{"type":"redacted_thinking"}';
}
return '0.7';
};
const SYNC_TARGET_TYPE_OPTIONS = [
{ label: '请求体字段', value: 'json' },
{ label: '请求头字段', value: 'header' },
];
const LEGACY_TEMPLATE = {
temperature: 0,
max_tokens: 1000,
};
const OPERATION_TEMPLATE = {
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'openai/',
},
],
logic: 'AND',
},
],
};
const HEADER_PASSTHROUGH_TEMPLATE = {
operations: [
{
mode: 'pass_headers',
value: ['Authorization'],
keep_origin: true,
},
],
};
const GEMINI_IMAGE_4K_TEMPLATE = {
operations: [
{
mode: 'set',
path: 'generationConfig.imageConfig.imageSize',
value: '4K',
conditions: [
{
path: 'original_model',
mode: 'contains',
value: 'gemini-3-pro-image-preview',
},
],
logic: 'AND',
},
],
};
const TEMPLATE_GROUP_OPTIONS = [
{ label: '基础模板', value: 'basic' },
{ label: '场景模板', value: 'scenario' },
];
const TEMPLATE_PRESET_CONFIG = {
operations_default: {
group: 'basic',
label: '新格式模板(规则集)',
kind: 'operations',
payload: OPERATION_TEMPLATE,
},
legacy_default: {
group: 'basic',
label: '旧格式模板JSON 对象)',
kind: 'legacy',
payload: LEGACY_TEMPLATE,
},
pass_headers_auth: {
group: 'scenario',
label: '请求头透传Authorization',
kind: 'operations',
payload: HEADER_PASSTHROUGH_TEMPLATE,
},
gemini_image_4k: {
group: 'scenario',
label: 'Gemini 图片 4K',
kind: 'operations',
payload: GEMINI_IMAGE_4K_TEMPLATE,
},
};
const FIELD_GUIDE_TARGET_OPTIONS = [
{ label: '填入目标路径', value: 'path' },
{ label: '填入来源字段', value: 'from' },
{ label: '填入目标字段', value: 'to' },
];
const BUILTIN_FIELD_SECTIONS = [
{
title: '常用请求字段',
fields: [
{
key: 'model',
label: '模型名称',
tip: '支持多级模型名,例如 openai/gpt-4o-mini',
},
{ key: 'temperature', label: '采样温度', tip: '控制输出随机性' },
{ key: 'max_tokens', label: '最大输出 Token', tip: '控制输出长度上限' },
{ key: 'messages.-1.content', label: '最后一条消息内容', tip: '常用于重写用户输入' },
],
},
{
title: '上下文字段',
fields: [
{ key: 'retry.is_retry', label: '是否重试', tip: 'true 表示重试请求' },
{ key: 'last_error.code', label: '上次错误码', tip: '配合重试策略使用' },
{
key: 'metadata.conversation_id',
label: '会话 ID',
tip: '可用于路由或缓存命中',
},
],
},
{
title: '请求头映射字段',
fields: [
{
key: 'header_override_normalized.authorization',
label: '标准化 Authorization',
tip: '统一小写后可稳定匹配',
},
{
key: 'header_override_normalized.x_debug_mode',
label: '标准化 X-Debug-Mode',
tip: '适合灰度 / 调试开关判断',
},
],
},
];
const OPERATION_MODE_LABEL_MAP = OPERATION_MODE_OPTIONS.reduce((acc, item) => {
acc[item.value] = item.label;
return acc;
}, {});
let localIdSeed = 0;
const nextLocalId = () => `param_override_${Date.now()}_${localIdSeed++}`;
const toValueText = (value) => {
if (value === undefined) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
};
const parseLooseValue = (valueText) => {
const raw = String(valueText ?? '');
if (raw.trim() === '') return '';
try {
return JSON.parse(raw);
} catch (error) {
return raw;
}
};
const parsePassHeaderNames = (rawValue) => {
if (Array.isArray(rawValue)) {
return rawValue
.map((item) => String(item ?? '').trim())
.filter(Boolean);
}
if (rawValue && typeof rawValue === 'object') {
if (Array.isArray(rawValue.headers)) {
return rawValue.headers
.map((item) => String(item ?? '').trim())
.filter(Boolean);
}
if (rawValue.header !== undefined) {
const single = String(rawValue.header ?? '').trim();
return single ? [single] : [];
}
return [];
}
if (typeof rawValue === 'string') {
return rawValue
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return [];
};
const parseReturnErrorDraft = (valueText) => {
const defaults = {
message: '',
statusCode: 400,
code: '',
type: '',
skipRetry: true,
simpleMode: true,
};
const raw = String(valueText ?? '').trim();
if (!raw) {
return defaults;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const statusRaw =
parsed.status_code !== undefined ? parsed.status_code : parsed.status;
const statusValue = Number(statusRaw);
return {
...defaults,
message: String(parsed.message || parsed.msg || '').trim(),
statusCode:
Number.isInteger(statusValue) &&
statusValue >= 100 &&
statusValue <= 599
? statusValue
: 400,
code: String(parsed.code || '').trim(),
type: String(parsed.type || '').trim(),
skipRetry: parsed.skip_retry !== false,
simpleMode: false,
};
}
} catch (error) {
// treat as plain text message
}
return {
...defaults,
message: raw,
simpleMode: true,
};
};
const buildReturnErrorValueText = (draft = {}) => {
const message = String(draft.message || '').trim();
if (draft.simpleMode) {
return message;
}
const statusCode = Number(draft.statusCode);
const payload = {
message,
status_code:
Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599
? statusCode
: 400,
};
const code = String(draft.code || '').trim();
const type = String(draft.type || '').trim();
if (code) payload.code = code;
if (type) payload.type = type;
if (draft.skipRetry === false) {
payload.skip_retry = false;
}
return JSON.stringify(payload);
};
const normalizePruneRule = (rule = {}) => ({
id: nextLocalId(),
path: typeof rule.path === 'string' ? rule.path : '',
mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full',
value_text: toValueText(rule.value),
invert: rule.invert === true,
pass_missing_key: rule.pass_missing_key === true,
});
const parsePruneObjectsDraft = (valueText) => {
const defaults = {
simpleMode: true,
typeText: '',
logic: 'AND',
recursive: true,
rules: [],
};
const raw = String(valueText ?? '').trim();
if (!raw) {
return defaults;
}
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'string') {
return {
...defaults,
simpleMode: true,
typeText: parsed.trim(),
};
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const rules = [];
if (parsed.where && typeof parsed.where === 'object' && !Array.isArray(parsed.where)) {
Object.entries(parsed.where).forEach(([path, value]) => {
rules.push(
normalizePruneRule({
path,
mode: 'full',
value,
}),
);
});
}
if (Array.isArray(parsed.conditions)) {
parsed.conditions.forEach((item) => {
if (item && typeof item === 'object') {
rules.push(normalizePruneRule(item));
}
});
} else if (
parsed.conditions &&
typeof parsed.conditions === 'object' &&
!Array.isArray(parsed.conditions)
) {
Object.entries(parsed.conditions).forEach(([path, value]) => {
rules.push(
normalizePruneRule({
path,
mode: 'full',
value,
}),
);
});
}
const typeText =
parsed.type === undefined ? '' : String(parsed.type).trim();
const logic =
String(parsed.logic || 'AND').toUpperCase() === 'OR' ? 'OR' : 'AND';
const recursive = parsed.recursive !== false;
const hasAdvancedFields =
parsed.logic !== undefined ||
parsed.recursive !== undefined ||
parsed.where !== undefined ||
parsed.conditions !== undefined;
return {
...defaults,
simpleMode: !hasAdvancedFields,
typeText,
logic,
recursive,
rules,
};
}
return {
...defaults,
simpleMode: true,
typeText: String(parsed ?? '').trim(),
};
} catch (error) {
return {
...defaults,
simpleMode: true,
typeText: raw,
};
}
};
const buildPruneObjectsValueText = (draft = {}) => {
const typeText = String(draft.typeText || '').trim();
if (draft.simpleMode) {
return typeText;
}
const payload = {};
if (typeText) {
payload.type = typeText;
}
if (String(draft.logic || 'AND').toUpperCase() === 'OR') {
payload.logic = 'OR';
}
if (draft.recursive === false) {
payload.recursive = false;
}
const conditions = (draft.rules || [])
.filter((rule) => String(rule.path || '').trim())
.map((rule) => {
const conditionPayload = {
path: String(rule.path || '').trim(),
mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full',
};
const valueRaw = String(rule.value_text || '').trim();
if (valueRaw !== '') {
conditionPayload.value = parseLooseValue(valueRaw);
}
if (rule.invert) {
conditionPayload.invert = true;
}
if (rule.pass_missing_key) {
conditionPayload.pass_missing_key = true;
}
return conditionPayload;
});
if (conditions.length > 0) {
payload.conditions = conditions;
}
if (!payload.type && !payload.conditions) {
return JSON.stringify({ logic: 'AND' });
}
return JSON.stringify(payload);
};
const parseSyncTargetSpec = (spec) => {
const raw = String(spec ?? '').trim();
if (!raw) return { type: 'json', key: '' };
const idx = raw.indexOf(':');
if (idx < 0) return { type: 'json', key: raw };
const prefix = raw.slice(0, idx).trim().toLowerCase();
const key = raw.slice(idx + 1).trim();
if (prefix === 'header') {
return { type: 'header', key };
}
return { type: 'json', key };
};
const buildSyncTargetSpec = (type, key) => {
const normalizedType = type === 'header' ? 'header' : 'json';
const normalizedKey = String(key ?? '').trim();
if (!normalizedKey) return '';
return `${normalizedType}:${normalizedKey}`;
};
const normalizeCondition = (condition = {}) => ({
id: nextLocalId(),
path: typeof condition.path === 'string' ? condition.path : '',
mode: CONDITION_MODE_VALUES.has(condition.mode) ? condition.mode : 'full',
value_text: toValueText(condition.value),
invert: condition.invert === true,
pass_missing_key: condition.pass_missing_key === true,
});
const createDefaultCondition = () => normalizeCondition({});
const normalizeOperation = (operation = {}) => ({
id: nextLocalId(),
path: typeof operation.path === 'string' ? operation.path : '',
mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
value_text: toValueText(operation.value),
keep_origin: operation.keep_origin === true,
from: typeof operation.from === 'string' ? operation.from : '',
to: typeof operation.to === 'string' ? operation.to : '',
logic: String(operation.logic || 'OR').toUpperCase() === 'AND' ? 'AND' : 'OR',
conditions: Array.isArray(operation.conditions)
? operation.conditions.map(normalizeCondition)
: [],
});
const createDefaultOperation = () => normalizeOperation({ mode: 'set' });
const getOperationSummary = (operation = {}, index = 0) => {
const mode = operation.mode || 'set';
const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode;
if (mode === 'sync_fields') {
const from = String(operation.from || '').trim();
const to = String(operation.to || '').trim();
return `${index + 1}. ${modeLabel} · ${from || to || '-'}`;
}
const path = String(operation.path || '').trim();
const from = String(operation.from || '').trim();
const to = String(operation.to || '').trim();
return `${index + 1}. ${modeLabel} · ${path || from || to || '-'}`;
};
const getOperationModeTagColor = (mode = 'set') => {
if (mode.includes('header')) return 'cyan';
if (mode.includes('replace') || mode.includes('trim')) return 'violet';
if (mode.includes('copy') || mode.includes('move')) return 'blue';
if (mode.includes('error') || mode.includes('prune')) return 'red';
if (mode.includes('sync')) return 'green';
return 'grey';
};
const parseInitialState = (rawValue) => {
const text = typeof rawValue === 'string' ? rawValue : '';
const trimmed = text.trim();
if (!trimmed) {
return {
editMode: 'visual',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: '',
jsonError: '',
};
}
if (!verifyJSON(trimmed)) {
return {
editMode: 'json',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: text,
jsonError: 'JSON 格式不正确',
};
}
const parsed = JSON.parse(trimmed);
const pretty = JSON.stringify(parsed, null, 2);
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Array.isArray(parsed.operations)
) {
return {
editMode: 'visual',
visualMode: 'operations',
legacyValue: '',
operations:
parsed.operations.length > 0
? parsed.operations.map(normalizeOperation)
: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
};
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return {
editMode: 'visual',
visualMode: 'legacy',
legacyValue: pretty,
operations: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
};
}
return {
editMode: 'json',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
};
};
const isOperationBlank = (operation) => {
const hasCondition = (operation.conditions || []).some(
(condition) =>
condition.path.trim() ||
String(condition.value_text ?? '').trim() ||
condition.mode !== 'full' ||
condition.invert ||
condition.pass_missing_key,
);
return (
operation.mode === 'set' &&
!operation.path.trim() &&
!operation.from.trim() &&
!operation.to.trim() &&
String(operation.value_text ?? '').trim() === '' &&
!operation.keep_origin &&
!hasCondition
);
};
const buildConditionPayload = (condition) => {
const path = condition.path.trim();
if (!path) return null;
const payload = {
path,
mode: condition.mode || 'full',
value: parseLooseValue(condition.value_text),
};
if (condition.invert) payload.invert = true;
if (condition.pass_missing_key) payload.pass_missing_key = true;
return payload;
};
const validateOperations = (operations, t) => {
for (let i = 0; i < operations.length; i++) {
const op = operations[i];
const mode = op.mode || 'set';
const meta = MODE_META[mode] || MODE_META.set;
const line = i + 1;
const pathValue = op.path.trim();
const fromValue = op.from.trim();
const toValue = op.to.trim();
if (meta.path && !pathValue) {
return t('第 {{line}} 条操作缺少目标路径', { line });
}
if (FROM_REQUIRED_MODES.has(mode) && !fromValue) {
if (!(meta.pathAlias && pathValue)) {
return t('第 {{line}} 条操作缺少来源字段', { line });
}
}
if (TO_REQUIRED_MODES.has(mode) && !toValue) {
if (!(meta.pathAlias && pathValue)) {
return t('第 {{line}} 条操作缺少目标字段', { line });
}
}
if (meta.from && !fromValue) {
return t('第 {{line}} 条操作缺少来源字段', { line });
}
if (meta.to && !toValue) {
return t('第 {{line}} 条操作缺少目标字段', { line });
}
if (
VALUE_REQUIRED_MODES.has(mode) &&
String(op.value_text ?? '').trim() === ''
) {
return t('第 {{line}} 条操作缺少值', { line });
}
if (mode === 'return_error') {
const raw = String(op.value_text ?? '').trim();
if (!raw) {
return t('第 {{line}} 条操作缺少值', { line });
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
if (!String(parsed.message || '').trim()) {
return t('第 {{line}} 条 return_error 需要 message 字段', { line });
}
}
} catch (error) {
// plain string value is allowed
}
}
if (mode === 'prune_objects') {
const raw = String(op.value_text ?? '').trim();
if (!raw) {
return t('第 {{line}} 条 prune_objects 缺少条件', { line });
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const hasType =
parsed.type !== undefined &&
String(parsed.type).trim() !== '';
const hasWhere =
parsed.where &&
typeof parsed.where === 'object' &&
!Array.isArray(parsed.where) &&
Object.keys(parsed.where).length > 0;
const hasConditionsArray =
Array.isArray(parsed.conditions) && parsed.conditions.length > 0;
const hasConditionsObject =
parsed.conditions &&
typeof parsed.conditions === 'object' &&
!Array.isArray(parsed.conditions) &&
Object.keys(parsed.conditions).length > 0;
if (!hasType && !hasWhere && !hasConditionsArray && !hasConditionsObject) {
return t('第 {{line}} 条 prune_objects 需要至少一个匹配条件', {
line,
});
}
}
} catch (error) {
// non-JSON string is treated as type string
}
}
if (mode === 'pass_headers') {
const raw = String(op.value_text ?? '').trim();
if (!raw) {
return t('第 {{line}} 条请求头透传缺少请求头名称', { line });
}
const parsed = parseLooseValue(raw);
const headers = parsePassHeaderNames(parsed);
if (headers.length === 0) {
return t('第 {{line}} 条请求头透传格式无效', { line });
}
}
}
return '';
};
const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
const { t } = useTranslation();
const [editMode, setEditMode] = useState('visual');
const [visualMode, setVisualMode] = useState('operations');
const [legacyValue, setLegacyValue] = useState('');
const [operations, setOperations] = useState([createDefaultOperation()]);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState('');
const [operationSearch, setOperationSearch] = useState('');
const [selectedOperationId, setSelectedOperationId] = useState('');
const [expandedConditionMap, setExpandedConditionMap] = useState({});
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
const [fieldGuideTarget, setFieldGuideTarget] = useState('path');
const [fieldGuideKeyword, setFieldGuideKeyword] = useState('');
useEffect(() => {
if (!visible) return;
const nextState = parseInitialState(value);
setEditMode(nextState.editMode);
setVisualMode(nextState.visualMode);
setLegacyValue(nextState.legacyValue);
setOperations(nextState.operations);
setJsonText(nextState.jsonText);
setJsonError(nextState.jsonError);
setOperationSearch('');
setSelectedOperationId(nextState.operations[0]?.id || '');
setExpandedConditionMap({});
if (nextState.visualMode === 'legacy') {
setTemplateGroupKey('basic');
setTemplatePresetKey('legacy_default');
} else {
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
}
setFieldGuideVisible(false);
setFieldGuideTarget('path');
setFieldGuideKeyword('');
}, [visible, value]);
useEffect(() => {
if (operations.length === 0) {
setSelectedOperationId('');
return;
}
if (!operations.some((item) => item.id === selectedOperationId)) {
setSelectedOperationId(operations[0].id);
}
}, [operations, selectedOperationId]);
const templatePresetOptions = useMemo(
() =>
Object.entries(TEMPLATE_PRESET_CONFIG)
.filter(([, config]) => config.group === templateGroupKey)
.map(([value, config]) => ({
value,
label: config.label,
})),
[templateGroupKey],
);
useEffect(() => {
if (templatePresetOptions.length === 0) return;
const exists = templatePresetOptions.some(
(item) => item.value === templatePresetKey,
);
if (!exists) {
setTemplatePresetKey(templatePresetOptions[0].value);
}
}, [templatePresetKey, templatePresetOptions]);
const operationCount = useMemo(
() => operations.filter((item) => !isOperationBlank(item)).length,
[operations],
);
const filteredOperations = useMemo(() => {
const keyword = operationSearch.trim().toLowerCase();
if (!keyword) return operations;
return operations.filter((operation) => {
const searchableText = [
operation.mode,
operation.path,
operation.from,
operation.to,
operation.value_text,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return searchableText.includes(keyword);
});
}, [operationSearch, operations]);
const selectedOperation = useMemo(
() => operations.find((operation) => operation.id === selectedOperationId),
[operations, selectedOperationId],
);
const selectedOperationIndex = useMemo(
() =>
operations.findIndex((operation) => operation.id === selectedOperationId),
[operations, selectedOperationId],
);
const returnErrorDraft = useMemo(() => {
if (!selectedOperation || (selectedOperation.mode || '') !== 'return_error') {
return null;
}
return parseReturnErrorDraft(selectedOperation.value_text);
}, [selectedOperation]);
const pruneObjectsDraft = useMemo(() => {
if (!selectedOperation || (selectedOperation.mode || '') !== 'prune_objects') {
return null;
}
return parsePruneObjectsDraft(selectedOperation.value_text);
}, [selectedOperation]);
const topOperationModes = useMemo(() => {
const counts = operations.reduce((acc, operation) => {
const mode = operation.mode || 'set';
acc[mode] = (acc[mode] || 0) + 1;
return acc;
}, {});
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
}, [operations]);
const buildOperationsJson = useCallback(
(sourceOperations, options = {}) => {
const { validate = true } = options;
const filteredOps = sourceOperations.filter((item) => !isOperationBlank(item));
if (filteredOps.length === 0) return '';
if (validate) {
const message = validateOperations(filteredOps, t);
if (message) {
throw new Error(message);
}
}
const payloadOps = filteredOps.map((operation) => {
const mode = operation.mode || 'set';
const meta = MODE_META[mode] || MODE_META.set;
const pathValue = operation.path.trim();
const fromValue = operation.from.trim();
const toValue = operation.to.trim();
const payload = { mode };
if (meta.path) {
payload.path = pathValue;
}
if (meta.pathOptional && pathValue) {
payload.path = pathValue;
}
if (meta.value) {
payload.value = parseLooseValue(operation.value_text);
}
if (meta.keepOrigin && operation.keep_origin) {
payload.keep_origin = true;
}
if (meta.from) {
payload.from = fromValue;
}
if (!meta.to && operation.to.trim()) {
payload.to = toValue;
}
if (meta.to) {
payload.to = toValue;
}
if (meta.pathAlias) {
if (!payload.from && pathValue) {
payload.from = pathValue;
}
if (!payload.to && pathValue) {
payload.to = pathValue;
}
}
const conditions = (operation.conditions || [])
.map(buildConditionPayload)
.filter(Boolean);
if (conditions.length > 0) {
payload.conditions = conditions;
payload.logic = operation.logic === 'AND' ? 'AND' : 'OR';
}
return payload;
});
return JSON.stringify({ operations: payloadOps }, null, 2);
},
[t],
);
const buildVisualJson = useCallback(() => {
if (visualMode === 'legacy') {
const trimmed = legacyValue.trim();
if (!trimmed) return '';
if (!verifyJSON(trimmed)) {
throw new Error(t('参数覆盖必须是合法的 JSON 格式!'));
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(t('旧格式必须是 JSON 对象'));
}
return JSON.stringify(parsed, null, 2);
}
return buildOperationsJson(operations, { validate: true });
}, [buildOperationsJson, legacyValue, operations, t, visualMode]);
const switchToJsonMode = () => {
if (editMode === 'json') return;
try {
setJsonText(buildVisualJson());
setJsonError('');
} catch (error) {
showError(error.message);
if (visualMode === 'legacy') {
setJsonText(legacyValue);
} else {
setJsonText(buildOperationsJson(operations, { validate: false }));
}
setJsonError(error.message || t('参数配置有误'));
}
setEditMode('json');
};
const switchToVisualMode = () => {
if (editMode === 'visual') return;
const trimmed = jsonText.trim();
if (!trimmed) {
const fallback = createDefaultOperation();
setVisualMode('operations');
setOperations([fallback]);
setSelectedOperationId(fallback.id);
setLegacyValue('');
setJsonError('');
setEditMode('visual');
return;
}
if (!verifyJSON(trimmed)) {
showError(t('参数覆盖必须是合法的 JSON 格式!'));
return;
}
const parsed = JSON.parse(trimmed);
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Array.isArray(parsed.operations)
) {
const nextOperations =
parsed.operations.length > 0
? parsed.operations.map(normalizeOperation)
: [createDefaultOperation()];
setVisualMode('operations');
setOperations(nextOperations);
setSelectedOperationId(nextOperations[0]?.id || '');
setLegacyValue('');
setJsonError('');
setEditMode('visual');
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
return;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const fallback = createDefaultOperation();
setVisualMode('legacy');
setLegacyValue(JSON.stringify(parsed, null, 2));
setOperations([fallback]);
setSelectedOperationId(fallback.id);
setJsonError('');
setEditMode('visual');
setTemplateGroupKey('basic');
setTemplatePresetKey('legacy_default');
return;
}
showError(t('参数覆盖必须是合法的 JSON 对象'));
};
const fillLegacyTemplate = (legacyPayload) => {
const text = JSON.stringify(legacyPayload, null, 2);
const fallback = createDefaultOperation();
setVisualMode('legacy');
setLegacyValue(text);
setOperations([fallback]);
setSelectedOperationId(fallback.id);
setExpandedConditionMap({});
setJsonText(text);
setJsonError('');
setEditMode('visual');
};
const fillOperationsTemplate = (operationsPayload) => {
const nextOperations = (operationsPayload || []).map(normalizeOperation);
const finalOperations =
nextOperations.length > 0 ? nextOperations : [createDefaultOperation()];
setVisualMode('operations');
setOperations(finalOperations);
setSelectedOperationId(finalOperations[0]?.id || '');
setExpandedConditionMap({});
setJsonText(JSON.stringify({ operations: operationsPayload || [] }, null, 2));
setJsonError('');
setEditMode('visual');
};
const appendLegacyTemplate = (legacyPayload) => {
let parsedCurrent = {};
if (visualMode === 'legacy') {
const trimmed = legacyValue.trim();
if (trimmed) {
if (!verifyJSON(trimmed)) {
showError(t('当前旧格式 JSON 不合法,无法追加模板'));
return;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
showError(t('当前旧格式不是 JSON 对象,无法追加模板'));
return;
}
parsedCurrent = parsed;
}
}
const merged = {
...(legacyPayload || {}),
...parsedCurrent,
};
const text = JSON.stringify(merged, null, 2);
const fallback = createDefaultOperation();
setVisualMode('legacy');
setLegacyValue(text);
setOperations([fallback]);
setSelectedOperationId(fallback.id);
setExpandedConditionMap({});
setJsonText(text);
setJsonError('');
setEditMode('visual');
};
const appendOperationsTemplate = (operationsPayload) => {
const appended = (operationsPayload || []).map(normalizeOperation);
const existing =
visualMode === 'operations'
? operations.filter((item) => !isOperationBlank(item))
: [];
const nextOperations = [...existing, ...appended];
setVisualMode('operations');
setOperations(nextOperations.length > 0 ? nextOperations : appended);
setSelectedOperationId(nextOperations[0]?.id || appended[0]?.id || '');
setExpandedConditionMap({});
setLegacyValue('');
setJsonError('');
setEditMode('visual');
setJsonText('');
};
const clearValue = () => {
const fallback = createDefaultOperation();
setVisualMode('operations');
setLegacyValue('');
setOperations([fallback]);
setSelectedOperationId(fallback.id);
setExpandedConditionMap({});
setJsonText('');
setJsonError('');
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
};
const getSelectedTemplatePreset = () =>
TEMPLATE_PRESET_CONFIG[templatePresetKey] ||
TEMPLATE_PRESET_CONFIG.operations_default;
const fillTemplateFromLibrary = () => {
const preset = getSelectedTemplatePreset();
if (preset.kind === 'legacy') {
fillLegacyTemplate(preset.payload || {});
return;
}
fillOperationsTemplate(preset.payload?.operations || []);
};
const appendTemplateFromLibrary = () => {
const preset = getSelectedTemplatePreset();
if (preset.kind === 'legacy') {
appendLegacyTemplate(preset.payload || {});
return;
}
appendOperationsTemplate(preset.payload?.operations || []);
};
const resetEditorState = () => {
clearValue();
setEditMode('visual');
};
const applyBuiltinField = (fieldKey, target = 'path') => {
if (!selectedOperation) {
showError(t('请先选择一条规则'));
return;
}
const mode = selectedOperation.mode || 'set';
const meta = MODE_META[mode] || MODE_META.set;
if (target === 'path' && (meta.path || meta.pathOptional || meta.pathAlias)) {
updateOperation(selectedOperation.id, { path: fieldKey });
return;
}
if (target === 'from' && (meta.from || meta.pathAlias || mode === 'sync_fields')) {
updateOperation(selectedOperation.id, {
from: mode === 'sync_fields' ? buildSyncTargetSpec('json', fieldKey) : fieldKey,
});
return;
}
if (target === 'to' && (meta.to || mode === 'sync_fields')) {
updateOperation(selectedOperation.id, {
to: mode === 'sync_fields' ? buildSyncTargetSpec('json', fieldKey) : fieldKey,
});
return;
}
showError(t('当前规则不支持写入到该位置'));
};
const openFieldGuide = (target = 'path') => {
setFieldGuideTarget(target);
setFieldGuideVisible(true);
};
const copyBuiltinField = async (fieldKey) => {
const ok = await copy(fieldKey);
if (ok) {
showSuccess(t('已复制字段:{{name}}', { name: fieldKey }));
} else {
showError(t('复制失败'));
}
};
const filteredFieldGuideSections = useMemo(() => {
const keyword = fieldGuideKeyword.trim().toLowerCase();
if (!keyword) {
return BUILTIN_FIELD_SECTIONS;
}
return BUILTIN_FIELD_SECTIONS.map((section) => ({
...section,
fields: section.fields.filter((field) =>
[field.key, field.label, field.tip]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(keyword),
),
})).filter((section) => section.fields.length > 0);
}, [fieldGuideKeyword]);
const fieldGuideActionLabel = useMemo(() => {
if (fieldGuideTarget === 'from') return t('填入来源');
if (fieldGuideTarget === 'to') return t('填入目标');
return t('填入路径');
}, [fieldGuideTarget, t]);
const fieldGuideFieldCount = useMemo(
() =>
filteredFieldGuideSections.reduce(
(total, section) => total + section.fields.length,
0,
),
[filteredFieldGuideSections],
);
const updateOperation = (operationId, patch) => {
setOperations((prev) =>
prev.map((item) =>
item.id === operationId ? { ...item, ...patch } : item,
),
);
};
const updateReturnErrorDraft = (operationId, draftPatch = {}) => {
const current = operations.find((item) => item.id === operationId);
if (!current) return;
const draft = parseReturnErrorDraft(current.value_text);
const nextDraft = { ...draft, ...draftPatch };
updateOperation(operationId, {
value_text: buildReturnErrorValueText(nextDraft),
});
};
const updatePruneObjectsDraft = (operationId, updater) => {
const current = operations.find((item) => item.id === operationId);
if (!current) return;
const draft = parsePruneObjectsDraft(current.value_text);
const nextDraft =
typeof updater === 'function'
? updater(draft)
: { ...draft, ...(updater || {}) };
updateOperation(operationId, {
value_text: buildPruneObjectsValueText(nextDraft),
});
};
const addPruneRule = (operationId) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
simpleMode: false,
rules: [...(draft.rules || []), normalizePruneRule({})],
}));
};
const updatePruneRule = (operationId, ruleId, patch) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
rules: (draft.rules || []).map((rule) =>
rule.id === ruleId ? { ...rule, ...patch } : rule,
),
}));
};
const removePruneRule = (operationId, ruleId) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
rules: (draft.rules || []).filter((rule) => rule.id !== ruleId),
}));
};
const addOperation = () => {
const created = createDefaultOperation();
setOperations((prev) => [...prev, created]);
setSelectedOperationId(created.id);
};
const duplicateOperation = (operationId) => {
let insertedId = '';
setOperations((prev) => {
const index = prev.findIndex((item) => item.id === operationId);
if (index < 0) return prev;
const source = prev[index];
const cloned = normalizeOperation({
path: source.path,
mode: source.mode,
value: parseLooseValue(source.value_text),
keep_origin: source.keep_origin,
from: source.from,
to: source.to,
logic: source.logic,
conditions: (source.conditions || []).map((condition) => ({
path: condition.path,
mode: condition.mode,
value: parseLooseValue(condition.value_text),
invert: condition.invert,
pass_missing_key: condition.pass_missing_key,
})),
});
insertedId = cloned.id;
const next = [...prev];
next.splice(index + 1, 0, cloned);
return next;
});
if (insertedId) {
setSelectedOperationId(insertedId);
}
};
const removeOperation = (operationId) => {
setOperations((prev) => {
if (prev.length <= 1) return [createDefaultOperation()];
return prev.filter((item) => item.id !== operationId);
});
setExpandedConditionMap((prev) => {
if (!Object.prototype.hasOwnProperty.call(prev, operationId)) {
return prev;
}
const next = { ...prev };
delete next[operationId];
return next;
});
};
const addCondition = (operationId) => {
const createdCondition = createDefaultCondition();
setOperations((prev) =>
prev.map((operation) =>
operation.id === operationId
? {
...operation,
conditions: [...(operation.conditions || []), createdCondition],
}
: operation,
),
);
setExpandedConditionMap((prev) => ({
...prev,
[operationId]: [...(prev[operationId] || []), createdCondition.id],
}));
};
const updateCondition = (operationId, conditionId, patch) => {
setOperations((prev) =>
prev.map((operation) => {
if (operation.id !== operationId) return operation;
return {
...operation,
conditions: (operation.conditions || []).map((condition) =>
condition.id === conditionId
? { ...condition, ...patch }
: condition,
),
};
}),
);
};
const removeCondition = (operationId, conditionId) => {
setOperations((prev) =>
prev.map((operation) => {
if (operation.id !== operationId) return operation;
return {
...operation,
conditions: (operation.conditions || []).filter(
(condition) => condition.id !== conditionId,
),
};
}),
);
setExpandedConditionMap((prev) => ({
...prev,
[operationId]: (prev[operationId] || []).filter(
(id) => id !== conditionId,
),
}));
};
const selectedConditionKeys = useMemo(
() => expandedConditionMap[selectedOperationId] || [],
[expandedConditionMap, selectedOperationId],
);
const handleConditionCollapseChange = useCallback(
(operationId, activeKeys) => {
const keys = (
Array.isArray(activeKeys) ? activeKeys : [activeKeys]
).filter(Boolean);
setExpandedConditionMap((prev) => ({
...prev,
[operationId]: keys,
}));
},
[],
);
const expandAllSelectedConditions = useCallback(() => {
if (!selectedOperationId || !selectedOperation) return;
setExpandedConditionMap((prev) => ({
...prev,
[selectedOperationId]: (selectedOperation.conditions || []).map(
(condition) => condition.id,
),
}));
}, [selectedOperation, selectedOperationId]);
const collapseAllSelectedConditions = useCallback(() => {
if (!selectedOperationId) return;
setExpandedConditionMap((prev) => ({
...prev,
[selectedOperationId]: [],
}));
}, [selectedOperationId]);
const handleJsonChange = (nextValue) => {
setJsonText(nextValue);
const trimmed = String(nextValue || '').trim();
if (!trimmed) {
setJsonError('');
return;
}
if (!verifyJSON(trimmed)) {
setJsonError(t('JSON格式错误'));
return;
}
setJsonError('');
};
const formatJson = () => {
const trimmed = jsonText.trim();
if (!trimmed) return;
if (!verifyJSON(trimmed)) {
showError(t('参数覆盖必须是合法的 JSON 格式!'));
return;
}
setJsonText(JSON.stringify(JSON.parse(trimmed), null, 2));
setJsonError('');
};
const visualValidationError = useMemo(() => {
if (editMode !== 'visual') {
return '';
}
try {
buildVisualJson();
return '';
} catch (error) {
return error?.message || t('参数配置有误');
}
}, [buildVisualJson, editMode, t]);
const handleSave = () => {
try {
let result = '';
if (editMode === 'json') {
const trimmed = jsonText.trim();
if (!trimmed) {
result = '';
} else {
if (!verifyJSON(trimmed)) {
throw new Error(t('参数覆盖必须是合法的 JSON 格式!'));
}
result = JSON.stringify(JSON.parse(trimmed), null, 2);
}
} else {
result = buildVisualJson();
}
onSave?.(result);
} catch (error) {
showError(error.message);
}
};
return (
<>
<Modal
title={t('参数覆盖')}
visible={visible}
width={1120}
bodyStyle={{ maxHeight: '76vh', overflowY: 'auto', paddingTop: 10 }}
onCancel={onCancel}
onOk={handleSave}
okText={t('保存')}
cancelText={t('取消')}
>
<Space vertical align='start' spacing={14} style={{ width: '100%' }}>
<Card
className='!rounded-xl !border-0 w-full'
bodyStyle={{
padding: 12,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='flex items-start justify-between gap-3'>
<Space wrap spacing={8}>
<Tag color='grey'>{t('编辑方式')}</Tag>
<Button
type={editMode === 'visual' ? 'primary' : 'tertiary'}
onClick={switchToVisualMode}
>
{t('可视化')}
</Button>
<Button
type={editMode === 'json' ? 'primary' : 'tertiary'}
onClick={switchToJsonMode}
>
{t('JSON 文本')}
</Button>
<Tag color='grey'>{t('模板')}</Tag>
<Select
value={templateGroupKey}
optionList={TEMPLATE_GROUP_OPTIONS}
onChange={(nextValue) =>
setTemplateGroupKey(nextValue || 'basic')
}
style={{ width: 120 }}
/>
<Select
value={templatePresetKey}
optionList={templatePresetOptions}
onChange={(nextValue) =>
setTemplatePresetKey(nextValue || 'operations_default')
}
style={{ width: 260 }}
/>
<Button onClick={fillTemplateFromLibrary}>{t('填充模板')}</Button>
<Button type='tertiary' onClick={appendTemplateFromLibrary}>
{t('追加模板')}
</Button>
<Button type='tertiary' onClick={resetEditorState}>
{t('重置')}
</Button>
</Space>
<Text
type='tertiary'
size='small'
className='cursor-pointer select-none mt-1 whitespace-nowrap'
onClick={() => openFieldGuide('path')}
>
{t('字段速查')}
</Text>
</div>
</Card>
{editMode === 'visual' ? (
<div style={{ width: '100%' }}>
{visualMode === 'legacy' ? (
<Card
className='!rounded-2xl !border-0'
bodyStyle={{
padding: 14,
background: 'var(--semi-color-fill-0)',
}}
>
<Text className='mb-2 block'>{t('旧格式JSON 对象)')}</Text>
<TextArea
value={legacyValue}
autosize={{ minRows: 10, maxRows: 20 }}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
onChange={(nextValue) => setLegacyValue(nextValue)}
showClear
/>
<Text type='tertiary' size='small' className='mt-2 block'>
{t('这里直接编辑 JSON 对象。适合简单覆盖参数的场景。')}
</Text>
</Card>
) : (
<div>
<div className='flex items-center justify-between mb-3'>
<Space>
<Text>{t('新格式(规则 + 条件)')}</Text>
<Tag color='cyan'>{`${t('规则')}: ${operationCount}`}</Tag>
</Space>
<Button icon={<IconPlus />} onClick={addOperation}>
{t('新增规则')}
</Button>
</div>
<Row gutter={12}>
<Col xs={24} md={8}>
<Card
className='!rounded-2xl !border-0 h-full'
bodyStyle={{
padding: 12,
background: 'var(--semi-color-fill-0)',
display: 'flex',
flexDirection: 'column',
gap: 10,
minHeight: 520,
}}
>
<div className='flex items-center justify-between'>
<Text strong>{t('规则导航')}</Text>
<Tag color='grey'>{`${operationCount}/${operations.length}`}</Tag>
</div>
{topOperationModes.length > 0 ? (
<Space wrap spacing={6}>
{topOperationModes.map(([mode, count]) => (
<Tag
key={`mode_stat_${mode}`}
size='small'
color={getOperationModeTagColor(mode)}
>
{`${OPERATION_MODE_LABEL_MAP[mode] || mode} · ${count}`}
</Tag>
))}
</Space>
) : null}
<Input
value={operationSearch}
placeholder={t('搜索规则(类型 / 路径 / 来源 / 目标)')}
onChange={(nextValue) =>
setOperationSearch(nextValue || '')
}
showClear
/>
<div
className='overflow-auto'
style={{ flex: 1, minHeight: 320, paddingRight: 2 }}
>
{filteredOperations.length === 0 ? (
<Text type='tertiary' size='small'>
{t('没有匹配的规则')}
</Text>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%',
}}
>
{filteredOperations.map((operation) => {
const index = operations.findIndex(
(item) => item.id === operation.id,
);
const isActive =
operation.id === selectedOperationId;
return (
<div
key={operation.id}
role='button'
tabIndex={0}
onClick={() =>
setSelectedOperationId(operation.id)
}
onKeyDown={(event) => {
if (
event.key === 'Enter' ||
event.key === ' '
) {
event.preventDefault();
setSelectedOperationId(operation.id);
}
}}
className='w-full rounded-xl px-3 py-3 cursor-pointer transition-colors'
style={{
background: isActive
? 'var(--semi-color-primary-light-default)'
: 'var(--semi-color-bg-2)',
border: isActive
? '1px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-start justify-between gap-2'>
<div>
<Text strong>{`#${index + 1}`}</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
>
{getOperationSummary(operation, index)}
</Text>
</div>
<Tag size='small' color='grey'>
{(operation.conditions || []).length}
</Tag>
</div>
<Space spacing={6} style={{ marginTop: 8 }}>
<Tag
size='small'
color={getOperationModeTagColor(
operation.mode || 'set',
)}
>
{OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'}
</Tag>
<Text type='tertiary' size='small'>
{t('条件数')}
</Text>
</Space>
</div>
);
})}
</div>
)}
</div>
</Card>
</Col>
<Col xs={24} md={16}>
{selectedOperation ? (
(() => {
const mode = selectedOperation.mode || 'set';
const meta = MODE_META[mode] || MODE_META.set;
const conditions = selectedOperation.conditions || [];
const syncFromTarget =
mode === 'sync_fields'
? parseSyncTargetSpec(selectedOperation.from)
: null;
const syncToTarget =
mode === 'sync_fields'
? parseSyncTargetSpec(selectedOperation.to)
: null;
return (
<Card
className='!rounded-2xl !border-0'
bodyStyle={{
padding: 14,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='flex items-center justify-between mb-3'>
<Space>
<Tag color='blue'>{`#${selectedOperationIndex + 1}`}</Tag>
<Text strong>
{getOperationSummary(
selectedOperation,
selectedOperationIndex,
)}
</Text>
</Space>
<Space>
<Button
size='small'
type='tertiary'
onClick={() =>
duplicateOperation(selectedOperation.id)
}
>
{t('复制')}
</Button>
<Button
size='small'
type='danger'
theme='borderless'
icon={<IconDelete />}
onClick={() =>
removeOperation(selectedOperation.id)
}
/>
</Space>
</div>
<Row gutter={12}>
<Col xs={24} md={8}>
<Text type='tertiary' size='small'>
{t('操作类型')}
</Text>
<Select
value={mode}
optionList={OPERATION_MODE_OPTIONS}
onChange={(nextMode) =>
updateOperation(selectedOperation.id, {
mode: nextMode,
})
}
style={{ width: '100%' }}
/>
</Col>
{meta.path || meta.pathOptional ? (
<Col xs={24} md={16}>
<Text type='tertiary' size='small'>
{meta.pathOptional
? t('目标路径(可选)')
: t(getModePathLabel(mode))}
</Text>
<Input
value={selectedOperation.path}
placeholder={getModePathPlaceholder(mode)}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
path: nextValue,
})
}
/>
</Col>
) : null}
</Row>
<Text
type='tertiary'
size='small'
className='mt-1 block'
>
{MODE_DESCRIPTIONS[mode] || ''}
</Text>
{meta.value ? (
mode === 'return_error' && returnErrorDraft ? (
<div
className='mt-2 rounded-xl p-3'
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('自定义错误响应')}</Text>
<Space spacing={6} align='center'>
<Text type='tertiary' size='small'>
{t('模式')}
</Text>
<Button
size='small'
type={
returnErrorDraft.simpleMode
? 'primary'
: 'tertiary'
}
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{ simpleMode: true },
)
}
>
{t('简洁')}
</Button>
<Button
size='small'
type={
returnErrorDraft.simpleMode
? 'tertiary'
: 'primary'
}
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{ simpleMode: false },
)
}
>
{t('高级')}
</Button>
</Space>
</div>
<Text type='tertiary' size='small'>
{t('错误消息(必填)')}
</Text>
<TextArea
value={returnErrorDraft.message}
autosize={{ minRows: 2, maxRows: 4 }}
placeholder={t('例如:该请求不满足准入策略')}
onChange={(nextValue) =>
updateReturnErrorDraft(
selectedOperation.id,
{ message: nextValue },
)
}
/>
{returnErrorDraft.simpleMode ? (
<Text
type='tertiary'
size='small'
className='mt-2 block'
>
{t(
'简洁模式仅返回 message状态码和错误类型将使用系统默认值。',
)}
</Text>
) : (
<>
<Row gutter={12} style={{ marginTop: 10 }}>
<Col xs={24} md={8}>
<Text type='tertiary' size='small'>
{t('状态码')}
</Text>
<Input
value={String(
returnErrorDraft.statusCode ?? '',
)}
placeholder='400'
onChange={(nextValue) =>
updateReturnErrorDraft(
selectedOperation.id,
{
statusCode:
parseInt(nextValue, 10) ||
400,
},
)
}
/>
</Col>
<Col xs={24} md={8}>
<Text type='tertiary' size='small'>
{t('错误代码(可选)')}
</Text>
<Input
value={returnErrorDraft.code}
placeholder='forced_bad_request'
onChange={(nextValue) =>
updateReturnErrorDraft(
selectedOperation.id,
{ code: nextValue },
)
}
/>
</Col>
<Col xs={24} md={8}>
<Text type='tertiary' size='small'>
{t('错误类型(可选)')}
</Text>
<Input
value={returnErrorDraft.type}
placeholder='invalid_request_error'
onChange={(nextValue) =>
updateReturnErrorDraft(
selectedOperation.id,
{ type: nextValue },
)
}
/>
</Col>
</Row>
<div className='mt-2 flex items-center gap-2'>
<Text type='tertiary' size='small'>
{t('重试建议')}
</Text>
<Button
size='small'
type={
returnErrorDraft.skipRetry
? 'primary'
: 'tertiary'
}
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{ skipRetry: true },
)
}
>
{t('停止重试')}
</Button>
<Button
size='small'
type={
returnErrorDraft.skipRetry
? 'tertiary'
: 'primary'
}
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{ skipRetry: false },
)
}
>
{t('允许重试')}
</Button>
</div>
<Space wrap style={{ marginTop: 8 }}>
<Tag
size='small'
color='grey'
className='cursor-pointer'
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{
statusCode: 400,
code: 'invalid_request',
type: 'invalid_request_error',
},
)
}
>
{t('参数错误')}
</Tag>
<Tag
size='small'
color='grey'
className='cursor-pointer'
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{
statusCode: 401,
code: 'unauthorized',
type: 'authentication_error',
},
)
}
>
{t('未授权')}
</Tag>
<Tag
size='small'
color='grey'
className='cursor-pointer'
onClick={() =>
updateReturnErrorDraft(
selectedOperation.id,
{
statusCode: 429,
code: 'rate_limited',
type: 'rate_limit_error',
},
)
}
>
{t('限流')}
</Tag>
</Space>
</>
)}
</div>
) : mode === 'prune_objects' && pruneObjectsDraft ? (
<div
className='mt-2 rounded-xl p-3'
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('对象清理规则')}</Text>
<Space spacing={6} align='center'>
<Text type='tertiary' size='small'>
{t('模式')}
</Text>
<Button
size='small'
type={
pruneObjectsDraft.simpleMode
? 'primary'
: 'tertiary'
}
onClick={() =>
updatePruneObjectsDraft(
selectedOperation.id,
{ simpleMode: true },
)
}
>
{t('简洁')}
</Button>
<Button
size='small'
type={
pruneObjectsDraft.simpleMode
? 'tertiary'
: 'primary'
}
onClick={() =>
updatePruneObjectsDraft(
selectedOperation.id,
{ simpleMode: false },
)
}
>
{t('高级')}
</Button>
</Space>
</div>
<Text type='tertiary' size='small'>
{t('类型(常用)')}
</Text>
<Input
value={pruneObjectsDraft.typeText}
placeholder='redacted_thinking'
onChange={(nextValue) =>
updatePruneObjectsDraft(
selectedOperation.id,
{ typeText: nextValue },
)
}
/>
{pruneObjectsDraft.simpleMode ? (
<Text
type='tertiary'
size='small'
className='mt-2 block'
>
{t(
'简洁模式:按 type 全量清理对象,例如 redacted_thinking。',
)}
</Text>
) : (
<>
<Row gutter={12} style={{ marginTop: 10 }}>
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t('逻辑')}
</Text>
<Select
value={pruneObjectsDraft.logic}
optionList={[
{ label: t('全部满足AND'), value: 'AND' },
{ label: t('任一满足OR'), value: 'OR' },
]}
style={{ width: '100%' }}
onChange={(nextValue) =>
updatePruneObjectsDraft(
selectedOperation.id,
{ logic: nextValue || 'AND' },
)
}
/>
</Col>
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t('递归策略')}
</Text>
<Space spacing={6} style={{ marginTop: 2 }}>
<Button
size='small'
type={
pruneObjectsDraft.recursive
? 'primary'
: 'tertiary'
}
onClick={() =>
updatePruneObjectsDraft(
selectedOperation.id,
{ recursive: true },
)
}
>
{t('递归')}
</Button>
<Button
size='small'
type={
pruneObjectsDraft.recursive
? 'tertiary'
: 'primary'
}
onClick={() =>
updatePruneObjectsDraft(
selectedOperation.id,
{ recursive: false },
)
}
>
{t('仅当前层')}
</Button>
</Space>
</Col>
</Row>
<div
className='mt-2 rounded-lg p-2'
style={{
background: 'var(--semi-color-fill-0)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Text strong>
{t('附加条件')}
</Text>
<Button
size='small'
icon={<IconPlus />}
onClick={() =>
addPruneRule(selectedOperation.id)
}
>
{t('新增条件')}
</Button>
</div>
{(pruneObjectsDraft.rules || []).length === 0 ? (
<Text type='tertiary' size='small'>
{t(
'未添加附加条件时,仅使用上方 type 进行清理。',
)}
</Text>
) : (
<div className='flex flex-col gap-2'>
{(pruneObjectsDraft.rules || []).map(
(rule, ruleIndex) => (
<div
key={rule.id}
className='rounded-lg p-2'
style={{
border:
'1px solid var(--semi-color-border)',
background:
'var(--semi-color-bg-0)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Tag size='small'>
{`R${ruleIndex + 1}`}
</Tag>
<Button
size='small'
type='danger'
theme='borderless'
icon={<IconDelete />}
onClick={() =>
removePruneRule(
selectedOperation.id,
rule.id,
)
}
>
{t('删除条件')}
</Button>
</div>
<Row gutter={8}>
<Col xs={24} md={9}>
<Text
type='tertiary'
size='small'
>
{t('字段路径')}
</Text>
<Input
value={rule.path}
placeholder='type'
onChange={(nextValue) =>
updatePruneRule(
selectedOperation.id,
rule.id,
{ path: nextValue },
)
}
/>
</Col>
<Col xs={24} md={7}>
<Text
type='tertiary'
size='small'
>
{t('匹配方式')}
</Text>
<Select
value={rule.mode}
optionList={
CONDITION_MODE_OPTIONS
}
style={{ width: '100%' }}
onChange={(nextValue) =>
updatePruneRule(
selectedOperation.id,
rule.id,
{ mode: nextValue },
)
}
/>
</Col>
<Col xs={24} md={8}>
<Text
type='tertiary'
size='small'
>
{t('匹配值(可选)')}
</Text>
<Input
value={rule.value_text}
placeholder='redacted_thinking'
onChange={(nextValue) =>
updatePruneRule(
selectedOperation.id,
rule.id,
{
value_text:
nextValue,
},
)
}
/>
</Col>
</Row>
<Space
wrap
spacing={8}
style={{ marginTop: 8 }}
>
<Button
size='small'
type={
rule.invert
? 'primary'
: 'tertiary'
}
onClick={() =>
updatePruneRule(
selectedOperation.id,
rule.id,
{
invert:
!rule.invert,
},
)
}
>
{t('条件取反')}
</Button>
<Button
size='small'
type={
rule.pass_missing_key
? 'primary'
: 'tertiary'
}
onClick={() =>
updatePruneRule(
selectedOperation.id,
rule.id,
{
pass_missing_key:
!rule.pass_missing_key,
},
)
}
>
{t('字段缺失视为命中')}
</Button>
</Space>
</div>
),
)}
</div>
)}
</div>
</>
)}
</div>
) : (
<div className='mt-2'>
<Text type='tertiary' size='small'>
{t(getModeValueLabel(mode))}
</Text>
<TextArea
value={selectedOperation.value_text}
autosize={{ minRows: 1, maxRows: 4 }}
placeholder={getModeValuePlaceholder(mode)}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
value_text: nextValue,
})
}
/>
</div>
)
) : null}
{meta.keepOrigin ? (
<div className='mt-2 flex items-center gap-2'>
<Switch
checked={Boolean(
selectedOperation.keep_origin,
)}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
keep_origin: nextValue,
})
}
/>
<Text
type='tertiary'
size='small'
className='leading-6'
>
{t('保留原值(目标已有值时不覆盖)')}
</Text>
</div>
) : null}
{mode === 'sync_fields' ? (
<div className='mt-2'>
<Text type='tertiary' size='small'>
{t('同步端点')}
</Text>
<Row gutter={12} style={{ marginTop: 6 }}>
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t('来源端点')}
</Text>
<div className='flex gap-2'>
<Select
value={syncFromTarget?.type || 'json'}
optionList={SYNC_TARGET_TYPE_OPTIONS}
style={{ width: 120 }}
onChange={(nextType) =>
updateOperation(
selectedOperation.id,
{
from: buildSyncTargetSpec(
nextType,
syncFromTarget?.key || '',
),
},
)
}
/>
<Input
value={syncFromTarget?.key || ''}
placeholder='session_id'
onChange={(nextKey) =>
updateOperation(
selectedOperation.id,
{
from: buildSyncTargetSpec(
syncFromTarget?.type || 'json',
nextKey,
),
},
)
}
/>
</div>
</Col>
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t('目标端点')}
</Text>
<div className='flex gap-2'>
<Select
value={syncToTarget?.type || 'json'}
optionList={SYNC_TARGET_TYPE_OPTIONS}
style={{ width: 120 }}
onChange={(nextType) =>
updateOperation(
selectedOperation.id,
{
to: buildSyncTargetSpec(
nextType,
syncToTarget?.key || '',
),
},
)
}
/>
<Input
value={syncToTarget?.key || ''}
placeholder='prompt_cache_key'
onChange={(nextKey) =>
updateOperation(
selectedOperation.id,
{
to: buildSyncTargetSpec(
syncToTarget?.type || 'json',
nextKey,
),
},
)
}
/>
</div>
</Col>
</Row>
<Space wrap style={{ marginTop: 8 }}>
<Tag
size='small'
color='cyan'
className='cursor-pointer'
onClick={() =>
updateOperation(selectedOperation.id, {
from: 'header:session_id',
to: 'json:prompt_cache_key',
})
}
>
{
'header:session_id -> json:prompt_cache_key'
}
</Tag>
<Tag
size='small'
color='cyan'
className='cursor-pointer'
onClick={() =>
updateOperation(selectedOperation.id, {
from: 'json:prompt_cache_key',
to: 'header:session_id',
})
}
>
{
'json:prompt_cache_key -> header:session_id'
}
</Tag>
</Space>
</div>
) : meta.from || meta.to === false || meta.to ? (
<Row gutter={12} style={{ marginTop: 8 }}>
{meta.from || meta.to === false ? (
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t(getModeFromLabel(mode))}
</Text>
<Input
value={selectedOperation.from}
placeholder={getModeFromPlaceholder(mode)}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
from: nextValue,
})
}
/>
</Col>
) : null}
{meta.to || meta.to === false ? (
<Col xs={24} md={12}>
<Text type='tertiary' size='small'>
{t(getModeToLabel(mode))}
</Text>
<Input
value={selectedOperation.to}
placeholder={getModeToPlaceholder(mode)}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
to: nextValue,
})
}
/>
</Col>
) : null}
</Row>
) : null}
<div
className='mt-3 rounded-xl p-3'
style={{
background: 'rgba(127, 127, 127, 0.08)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Space align='center'>
<Text>{t('条件规则')}</Text>
<Select
value={selectedOperation.logic || 'OR'}
optionList={[
{ label: t('满足任一条件OR'), value: 'OR' },
{ label: t('必须全部满足AND'), value: 'AND' },
]}
size='small'
style={{ width: 180 }}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
logic: nextValue,
})
}
/>
</Space>
<Space spacing={6}>
<Button
size='small'
type='tertiary'
onClick={expandAllSelectedConditions}
>
{t('全部展开')}
</Button>
<Button
size='small'
type='tertiary'
onClick={collapseAllSelectedConditions}
>
{t('全部收起')}
</Button>
<Button
icon={<IconPlus />}
size='small'
onClick={() =>
addCondition(selectedOperation.id)
}
>
{t('新增条件')}
</Button>
</Space>
</div>
{conditions.length === 0 ? (
<Text type='tertiary' size='small'>
{t('没有条件时,默认总是执行该操作。')}
</Text>
) : (
<Collapse
keepDOM
activeKey={selectedConditionKeys}
onChange={(activeKeys) =>
handleConditionCollapseChange(
selectedOperation.id,
activeKeys,
)
}
>
{conditions.map(
(condition, conditionIndex) => (
<Collapse.Panel
key={condition.id}
itemKey={condition.id}
header={
<Space spacing={8}>
<Tag size='small'>
{`C${conditionIndex + 1}`}
</Tag>
<Text type='tertiary' size='small'>
{condition.path ||
t('未设置路径')}
</Text>
</Space>
}
>
<div>
<div className='flex items-center justify-between mb-2'>
<Text type='tertiary' size='small'>
{t('条件项设置')}
</Text>
<Button
theme='borderless'
type='danger'
icon={<IconDelete />}
size='small'
onClick={() =>
removeCondition(
selectedOperation.id,
condition.id,
)
}
>
{t('删除条件')}
</Button>
</div>
<Row gutter={12}>
<Col xs={24} md={10}>
<Text
type='tertiary'
size='small'
>
{t('字段路径')}
</Text>
<Input
value={condition.path}
placeholder='model'
onChange={(nextValue) =>
updateCondition(
selectedOperation.id,
condition.id,
{ path: nextValue },
)
}
/>
</Col>
<Col xs={24} md={8}>
<Text
type='tertiary'
size='small'
>
{t('匹配方式')}
</Text>
<Select
value={condition.mode}
optionList={
CONDITION_MODE_OPTIONS
}
onChange={(nextValue) =>
updateCondition(
selectedOperation.id,
condition.id,
{ mode: nextValue },
)
}
style={{ width: '100%' }}
/>
</Col>
<Col xs={24} md={6}>
<Text
type='tertiary'
size='small'
>
{t('匹配值')}
</Text>
<Input
value={condition.value_text}
placeholder='gpt'
onChange={(nextValue) =>
updateCondition(
selectedOperation.id,
condition.id,
{ value_text: nextValue },
)
}
/>
</Col>
</Row>
<div className='mt-2 flex flex-wrap gap-3'>
<div className='flex items-center gap-2'>
<Text type='tertiary' size='small'>
{t('条件取反')}
</Text>
<Switch
checked={Boolean(
condition.invert,
)}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(nextValue) =>
updateCondition(
selectedOperation.id,
condition.id,
{ invert: nextValue },
)
}
/>
</div>
<div className='flex items-center gap-2'>
<Text type='tertiary' size='small'>
{t('字段缺失视为命中')}
</Text>
<Switch
checked={Boolean(
condition.pass_missing_key,
)}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(nextValue) =>
updateCondition(
selectedOperation.id,
condition.id,
{
pass_missing_key: nextValue,
},
)
}
/>
</div>
</div>
</div>
</Collapse.Panel>
),
)}
</Collapse>
)}
</div>
</Card>
);
})()
) : (
<Card
className='!rounded-2xl !border-0'
bodyStyle={{
padding: 14,
background: 'var(--semi-color-fill-0)',
}}
>
<Text type='tertiary'>
{t('请选择一条规则进行编辑。')}
</Text>
</Card>
)}
{visualValidationError ? (
<Card
className='!rounded-2xl !border-0 mt-3'
bodyStyle={{
padding: 12,
background: 'var(--semi-color-fill-0)',
}}
>
<Space>
<Tag color='red'>{t('暂存错误')}</Tag>
<Text type='danger'>{visualValidationError}</Text>
</Space>
</Card>
) : null}
</Col>
</Row>
</div>
)}
</div>
) : (
<div style={{ width: '100%' }}>
<Space style={{ marginBottom: 8 }} wrap>
<Button onClick={formatJson}>{t('格式化')}</Button>
<Tag color='grey'>{t('高级文本编辑')}</Tag>
</Space>
<TextArea
value={jsonText}
autosize={{ minRows: 18, maxRows: 28 }}
onChange={(nextValue) => handleJsonChange(nextValue ?? '')}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
showClear
/>
<Text type='tertiary' size='small' className='mt-2 block'>
{t('直接编辑 JSON 文本,保存时会校验格式。')}
</Text>
{jsonError ? (
<Text className='text-red-500 text-xs mt-2'>{jsonError}</Text>
) : null}
</div>
)}
</Space>
</Modal>
<Modal
title={null}
visible={fieldGuideVisible}
width={860}
footer={null}
onCancel={() => setFieldGuideVisible(false)}
bodyStyle={{
maxHeight: '72vh',
overflowY: 'auto',
padding: 16,
background: 'var(--semi-color-bg-0)',
}}
>
<Space vertical spacing={12} style={{ width: '100%' }}>
<div className='flex items-start justify-between gap-3'>
<div>
<Text strong style={{ fontSize: 22, lineHeight: '30px' }}>
{t('字段速查')}
</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{ maxWidth: 560 }}
>
{t(
'先搜索,再一键复制字段名或填入当前规则。字段名为系统内部路径,可直接用于路径 / 来源 / 目标。',
)}
</Text>
</div>
<Tag color='blue'>{`${fieldGuideFieldCount} ${t('个字段')}`}</Tag>
</div>
<Card
className='!rounded-xl !border-0'
bodyStyle={{
padding: 12,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='flex items-center gap-2'>
<Input
value={fieldGuideKeyword}
onChange={(nextValue) => setFieldGuideKeyword(nextValue || '')}
placeholder={t('搜索字段名 / 中文说明')}
showClear
style={{ flex: 1 }}
/>
<Select
value={fieldGuideTarget}
optionList={FIELD_GUIDE_TARGET_OPTIONS}
onChange={(nextValue) =>
setFieldGuideTarget(nextValue || 'path')
}
style={{ width: 170 }}
/>
</div>
</Card>
{filteredFieldGuideSections.length === 0 ? (
<Card
className='!rounded-xl !border-0'
bodyStyle={{
padding: 20,
background: 'var(--semi-color-fill-0)',
}}
>
<Text type='tertiary'>{t('没有匹配的字段')}</Text>
</Card>
) : (
<div className='flex flex-col gap-2'>
{filteredFieldGuideSections.map((section) => (
<Card
key={section.title}
className='!rounded-xl !border-0'
bodyStyle={{
padding: 14,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='flex items-center justify-between mb-1'>
<Text strong style={{ fontSize: 18 }}>
{section.title}
</Text>
<Tag color='grey'>{`${section.fields.length} ${t('项')}`}</Tag>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
marginTop: 6,
}}
>
{section.fields.map((field, index) => (
<div
key={field.key}
className='flex items-start justify-between gap-3'
style={{
paddingTop: 10,
paddingBottom: 10,
borderTop:
index === 0
? 'none'
: '1px solid var(--semi-color-border)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong>{field.label}</Text>
<Text
type='secondary'
size='small'
className='block mt-1 font-mono'
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)',
borderRadius: 8,
padding: '4px 8px',
width: 'fit-content',
}}
>
{field.key}
</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{ lineHeight: '18px' }}
>
{field.tip}
</Text>
</div>
<Space spacing={6} align='center'>
<Button
size='small'
type='tertiary'
onClick={() => copyBuiltinField(field.key)}
>
{t('复制')}
</Button>
<Button
size='small'
onClick={() =>
applyBuiltinField(field.key, fieldGuideTarget)
}
>
{fieldGuideActionLabel}
</Button>
</Space>
</div>
))}
</div>
</Card>
))}
</div>
)}
</Space>
</Modal>
</>
);
};
export default ParamOverrideEditorModal;