/* 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 . 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 ( <>
{t('编辑方式')} {t('模板')} setTemplatePresetKey(nextValue || 'operations_default') } style={{ width: 260 }} /> openFieldGuide('path')} > {t('字段速查')}
{editMode === 'visual' ? (
{visualMode === 'legacy' ? ( {t('旧格式(JSON 对象)')}