Spaces:
Runtime error
Runtime error
| /* | |
| 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, IconMenu, IconPlus } from '@douyinfe/semi-icons'; | |
| import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers'; | |
| import { | |
| CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE, | |
| CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE, | |
| } from '../../../../constants/channel-affinity-template.constants'; | |
| 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: '设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做删除、替换、追加或白名单保留', | |
| 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 '请求头值(支持字符串或 JSON 映射)'; | |
| 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 HEADER_VALUE_JSONC_EXAMPLE = `{ | |
| // 置空:删除 Bedrock 不支持的 beta特性 | |
| "files-api-2025-04-14": null, | |
| // 替换:把旧特性改成兼容特性 | |
| "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19", | |
| // 追加:在末尾补一个需要的特性 | |
| "$append": ["context-1m-2025-08-07"] | |
| }`; | |
| const getModeValuePlaceholder = (mode) => { | |
| if (mode === 'set_header') { | |
| return [ | |
| '纯字符串(整条覆盖):', | |
| 'Bearer sk-xxx', | |
| '', | |
| '或使用 JSON 规则:', | |
| '{', | |
| ' "files-api-2025-04-14": null,', | |
| ' "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",', | |
| ' "$append": ["context-1m-2025-08-07"]', | |
| '}', | |
| ].join('\n'); | |
| } | |
| 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: [ | |
| { | |
| description: 'Set default temperature for openai/* models.', | |
| path: 'temperature', | |
| mode: 'set', | |
| value: 0.7, | |
| conditions: [ | |
| { | |
| path: 'model', | |
| mode: 'prefix', | |
| value: 'openai/', | |
| }, | |
| ], | |
| logic: 'AND', | |
| }, | |
| ], | |
| }; | |
| const HEADER_PASSTHROUGH_TEMPLATE = { | |
| operations: [ | |
| { | |
| description: 'Pass through X-Request-Id header to upstream.', | |
| mode: 'pass_headers', | |
| value: ['X-Request-Id'], | |
| keep_origin: true, | |
| }, | |
| ], | |
| }; | |
| const GEMINI_IMAGE_4K_TEMPLATE = { | |
| operations: [ | |
| { | |
| description: | |
| 'Set imageSize to 4K when model contains gemini/image and ends with 4k.', | |
| mode: 'set', | |
| path: 'generationConfig.imageConfig.imageSize', | |
| value: '4K', | |
| conditions: [ | |
| { | |
| path: 'original_model', | |
| mode: 'contains', | |
| value: 'gemini', | |
| }, | |
| { | |
| path: 'original_model', | |
| mode: 'contains', | |
| value: 'image', | |
| }, | |
| { | |
| path: 'original_model', | |
| mode: 'suffix', | |
| value: '4k', | |
| }, | |
| ], | |
| logic: 'AND', | |
| }, | |
| ], | |
| }; | |
| const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = { | |
| operations: [ | |
| { | |
| description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.', | |
| mode: 'set_header', | |
| path: 'anthropic-beta', | |
| // https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json | |
| value: { | |
| 'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19', | |
| bash_20241022: null, | |
| bash_20250124: null, | |
| 'code-execution-2025-08-25': null, | |
| 'compact-2026-01-12': 'compact-2026-01-12', | |
| 'computer-use-2025-01-24': 'computer-use-2025-01-24', | |
| 'computer-use-2025-11-24': 'computer-use-2025-11-24', | |
| 'context-1m-2025-08-07': 'context-1m-2025-08-07', | |
| 'context-management-2025-06-27': 'context-management-2025-06-27', | |
| 'effort-2025-11-24': null, | |
| 'fast-mode-2026-02-01': null, | |
| 'files-api-2025-04-14': null, | |
| 'fine-grained-tool-streaming-2025-05-14': null, | |
| 'interleaved-thinking-2025-05-14': 'interleaved-thinking-2025-05-14', | |
| 'mcp-client-2025-11-20': null, | |
| 'mcp-client-2025-04-04': null, | |
| 'mcp-servers-2025-12-04': null, | |
| 'output-128k-2025-02-19': null, | |
| 'structured-output-2024-03-01': null, | |
| 'prompt-caching-scope-2026-01-05': null, | |
| 'skills-2025-10-02': null, | |
| 'structured-outputs-2025-11-13': null, | |
| text_editor_20241022: null, | |
| text_editor_20250124: null, | |
| 'token-efficient-tools-2025-02-19': null, | |
| 'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19', | |
| 'web-fetch-2025-09-10': null, | |
| 'web-search-2025-03-05': null, | |
| 'oauth-2025-04-20': null | |
| }, | |
| }, | |
| { | |
| description: 'Remove all tools[*].custom.input_examples before upstream relay.', | |
| mode: 'delete', | |
| path: 'tools.*.custom.input_examples', | |
| }, | |
| ], | |
| }; | |
| 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: '请求头透传(X-Request-Id)', | |
| kind: 'operations', | |
| payload: HEADER_PASSTHROUGH_TEMPLATE, | |
| }, | |
| gemini_image_4k: { | |
| group: 'scenario', | |
| label: 'Gemini 图片 4K', | |
| kind: 'operations', | |
| payload: GEMINI_IMAGE_4K_TEMPLATE, | |
| }, | |
| claude_cli_headers_passthrough: { | |
| group: 'scenario', | |
| label: 'Claude CLI 请求头透传', | |
| kind: 'operations', | |
| payload: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE, | |
| }, | |
| codex_cli_headers_passthrough: { | |
| group: 'scenario', | |
| label: 'Codex CLI 请求头透传', | |
| kind: 'operations', | |
| payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE, | |
| }, | |
| aws_bedrock_anthropic_beta_override: { | |
| group: 'scenario', | |
| label: 'AWS Bedrock Claude 兼容模板', | |
| kind: 'operations', | |
| payload: AWS_BEDROCK_ANTHROPIC_COMPAT_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(), | |
| description: typeof operation.description === 'string' ? operation.description : '', | |
| 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 reorderOperations = ( | |
| sourceOperations = [], | |
| sourceId, | |
| targetId, | |
| position = 'before', | |
| ) => { | |
| if (!sourceId || !targetId || sourceId === targetId) { | |
| return sourceOperations; | |
| } | |
| const sourceIndex = sourceOperations.findIndex((item) => item.id === sourceId); | |
| if (sourceIndex < 0) { | |
| return sourceOperations; | |
| } | |
| const nextOperations = [...sourceOperations]; | |
| const [moved] = nextOperations.splice(sourceIndex, 1); | |
| let insertIndex = nextOperations.findIndex((item) => item.id === targetId); | |
| if (insertIndex < 0) { | |
| return sourceOperations; | |
| } | |
| if (position === 'after') { | |
| insertIndex += 1; | |
| } | |
| nextOperations.splice(insertIndex, 0, moved); | |
| return nextOperations; | |
| }; | |
| 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 [draggedOperationId, setDraggedOperationId] = useState(''); | |
| const [dragOverOperationId, setDragOverOperationId] = useState(''); | |
| const [dragOverPosition, setDragOverPosition] = useState('before'); | |
| const [templateGroupKey, setTemplateGroupKey] = useState('basic'); | |
| const [templatePresetKey, setTemplatePresetKey] = useState('operations_default'); | |
| const [headerValueExampleVisible, setHeaderValueExampleVisible] = useState(false); | |
| 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({}); | |
| setDraggedOperationId(''); | |
| setDragOverOperationId(''); | |
| setDragOverPosition('before'); | |
| if (nextState.visualMode === 'legacy') { | |
| setTemplateGroupKey('basic'); | |
| setTemplatePresetKey('legacy_default'); | |
| } else { | |
| setTemplateGroupKey('basic'); | |
| setTemplatePresetKey('operations_default'); | |
| } | |
| setHeaderValueExampleVisible(false); | |
| 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.description, | |
| 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 descriptionValue = String(operation.description || '').trim(); | |
| const pathValue = operation.path.trim(); | |
| const fromValue = operation.from.trim(); | |
| const toValue = operation.to.trim(); | |
| const payload = { mode }; | |
| if (descriptionValue) { | |
| payload.description = descriptionValue; | |
| } | |
| 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 formatSelectedOperationValueAsJson = useCallback(() => { | |
| if (!selectedOperation) return; | |
| const raw = String(selectedOperation.value_text || '').trim(); | |
| if (!raw) return; | |
| if (!verifyJSON(raw)) { | |
| showError(t('当前值不是合法 JSON,无法格式化')); | |
| return; | |
| } | |
| try { | |
| updateOperation(selectedOperation.id, { | |
| value_text: JSON.stringify(JSON.parse(raw), null, 2), | |
| }); | |
| showSuccess(t('JSON 已格式化')); | |
| } catch (error) { | |
| showError(t('当前值不是合法 JSON,无法格式化')); | |
| } | |
| }, [selectedOperation, t, updateOperation]); | |
| 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 resetOperationDragState = useCallback(() => { | |
| setDraggedOperationId(''); | |
| setDragOverOperationId(''); | |
| setDragOverPosition('before'); | |
| }, []); | |
| const moveOperation = useCallback( | |
| (sourceId, targetId, position = 'before') => { | |
| if (!sourceId || !targetId || sourceId === targetId) { | |
| return; | |
| } | |
| setOperations((prev) => | |
| reorderOperations(prev, sourceId, targetId, position), | |
| ); | |
| setSelectedOperationId(sourceId); | |
| }, | |
| [], | |
| ); | |
| const handleOperationDragStart = useCallback((event, operationId) => { | |
| setDraggedOperationId(operationId); | |
| setSelectedOperationId(operationId); | |
| event.dataTransfer.effectAllowed = 'move'; | |
| event.dataTransfer.setData('text/plain', operationId); | |
| }, []); | |
| const handleOperationDragOver = useCallback( | |
| (event, operationId) => { | |
| event.preventDefault(); | |
| if (!draggedOperationId || draggedOperationId === operationId) { | |
| return; | |
| } | |
| const rect = event.currentTarget.getBoundingClientRect(); | |
| const position = | |
| event.clientY - rect.top > rect.height / 2 ? 'after' : 'before'; | |
| setDragOverOperationId(operationId); | |
| setDragOverPosition(position); | |
| event.dataTransfer.dropEffect = 'move'; | |
| }, | |
| [draggedOperationId], | |
| ); | |
| const handleOperationDrop = useCallback( | |
| (event, operationId) => { | |
| event.preventDefault(); | |
| const sourceId = | |
| draggedOperationId || event.dataTransfer.getData('text/plain'); | |
| const position = | |
| dragOverOperationId === operationId ? dragOverPosition : 'before'; | |
| moveOperation(sourceId, operationId, position); | |
| resetOperationDragState(); | |
| }, | |
| [ | |
| dragOverOperationId, | |
| dragOverPosition, | |
| draggedOperationId, | |
| moveOperation, | |
| resetOperationDragState, | |
| ], | |
| ); | |
| 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({ | |
| description: source.description, | |
| 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> | |
| </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; | |
| const isDragging = | |
| operation.id === draggedOperationId; | |
| const isDropTarget = | |
| operation.id === dragOverOperationId && | |
| draggedOperationId && | |
| draggedOperationId !== operation.id; | |
| return ( | |
| <div | |
| key={operation.id} | |
| role='button' | |
| tabIndex={0} | |
| draggable={operations.length > 1} | |
| onClick={() => | |
| setSelectedOperationId(operation.id) | |
| } | |
| onDragStart={(event) => | |
| handleOperationDragStart(event, operation.id) | |
| } | |
| onDragOver={(event) => | |
| handleOperationDragOver(event, operation.id) | |
| } | |
| onDrop={(event) => | |
| handleOperationDrop(event, operation.id) | |
| } | |
| onDragEnd={resetOperationDragState} | |
| 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)', | |
| opacity: isDragging ? 0.6 : 1, | |
| boxShadow: isDropTarget | |
| ? dragOverPosition === 'after' | |
| ? 'inset 0 -3px 0 var(--semi-color-primary)' | |
| : 'inset 0 3px 0 var(--semi-color-primary)' | |
| : 'none', | |
| }} | |
| > | |
| <div className='flex items-start justify-between gap-2'> | |
| <div className='flex items-start gap-2 min-w-0'> | |
| <div | |
| className='flex-shrink-0' | |
| style={{ | |
| color: 'var(--semi-color-text-2)', | |
| cursor: operations.length > 1 ? 'grab' : 'default', | |
| marginTop: 1, | |
| }} | |
| > | |
| <IconMenu /> | |
| </div> | |
| <div className='min-w-0'> | |
| <Text strong>{`#${index + 1}`}</Text> | |
| <Text | |
| type='tertiary' | |
| size='small' | |
| className='block mt-1' | |
| > | |
| {getOperationSummary(operation, index)} | |
| </Text> | |
| {String(operation.description || '').trim() ? ( | |
| <Text | |
| type='tertiary' | |
| size='small' | |
| className='block mt-1' | |
| style={{ | |
| lineHeight: 1.5, | |
| wordBreak: 'break-word', | |
| overflow: 'hidden', | |
| display: '-webkit-box', | |
| WebkitLineClamp: 2, | |
| WebkitBoxOrient: 'vertical', | |
| }} | |
| > | |
| {operation.description} | |
| </Text> | |
| ) : null} | |
| </div> | |
| </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 />} | |
| aria-label={t('删除规则')} | |
| 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> | |
| <div className='mt-2'> | |
| <Text type='tertiary' size='small'> | |
| {t('规则描述(可选)')} | |
| </Text> | |
| <Input | |
| value={selectedOperation.description || ''} | |
| placeholder={t('例如:清理工具参数,避免上游校验错误')} | |
| onChange={(nextValue) => | |
| updateOperation(selectedOperation.id, { | |
| description: nextValue || '', | |
| }) | |
| } | |
| maxLength={180} | |
| showClear | |
| /> | |
| <Text type='tertiary' size='small' className='mt-1 block'> | |
| {`${String(selectedOperation.description || '').length}/180`} | |
| </Text> | |
| </div> | |
| {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'> | |
| <div className='flex items-center justify-between gap-2'> | |
| <Text type='tertiary' size='small'> | |
| {t(getModeValueLabel(mode))} | |
| </Text> | |
| {mode === 'set_header' ? ( | |
| <Space spacing={6}> | |
| <Button | |
| size='small' | |
| type='tertiary' | |
| onClick={() => | |
| setHeaderValueExampleVisible(true) | |
| } | |
| > | |
| {t('查看 JSON 示例')} | |
| </Button> | |
| <Button | |
| size='small' | |
| type='tertiary' | |
| onClick={formatSelectedOperationValueAsJson} | |
| > | |
| {t('格式化 JSON')} | |
| </Button> | |
| </Space> | |
| ) : null} | |
| </div> | |
| {mode === 'set_header' ? ( | |
| <Text | |
| type='tertiary' | |
| size='small' | |
| className='mt-1 mb-2 block' | |
| > | |
| {t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')} | |
| </Text> | |
| ) : null} | |
| <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={t('anthropic-beta JSON 示例')} | |
| visible={headerValueExampleVisible} | |
| width={760} | |
| footer={null} | |
| onCancel={() => setHeaderValueExampleVisible(false)} | |
| bodyStyle={{ padding: 16, paddingBottom: 24 }} | |
| > | |
| <Space vertical align='start' spacing={12} style={{ width: '100%' }}> | |
| <Text type='tertiary' size='small'> | |
| {t('下面是带注释的示例,仅用于参考;实际保存时请删除注释。')} | |
| </Text> | |
| <TextArea | |
| value={HEADER_VALUE_JSONC_EXAMPLE} | |
| readOnly | |
| autosize={{ minRows: 16, maxRows: 20 }} | |
| style={{ marginBottom: 8 }} | |
| /> | |
| </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; | |