| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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', |
| |
| 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) { |
| |
| } |
|
|
| 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) { |
| |
| } |
| } |
|
|
| 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) { |
| |
| } |
| } |
|
|
| 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; |
|
|