mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-21 19:10:41 +08:00
772 lines
50 KiB
TypeScript
772 lines
50 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
AlertTriangle, ArrowDown, ArrowRight, Braces, CheckCircle2, ChevronDown, CirclePlus, Code2,
|
|
FileInput, FileKey2, FlaskConical, Link2, Play, Plus, RefreshCw, Save, Sparkles, Trash2, Unplug,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { errorMessage, request } from '@/platform/messaging/runtime';
|
|
import type {
|
|
ActiveTabInfo, BrowserPageCallable, BrowserRecordingEvent, BrowserTransformBuiltinOperation,
|
|
BrowserTransformDirection, BrowserTransformDirectionName, BrowserTransformExecution,
|
|
BrowserTransformNodeReference, BrowserTransformPipelineNode, BrowserTransformProfile,
|
|
BrowserTransformProfileInput, BrowserProfileInferenceCandidate,
|
|
} from '@/types/models';
|
|
import {
|
|
compileGuidedTransform, defaultGuidedTransform, guidedOutputDescription, parseGuidedTransform,
|
|
type GuidedTransformDraft, type GuidedTransformOutputKind,
|
|
} from './guided';
|
|
import { createBrowserTransformProfileInput } from './profile-draft';
|
|
import {
|
|
clearBrowserTransformReplayDraft,
|
|
deleteBrowserTransformReplayDrafts,
|
|
getBrowserTransformReplayDraft,
|
|
saveBrowserTransformReplayDraft,
|
|
type BrowserTransformReplayDraftFields,
|
|
type BrowserTransformReplayDraftInput,
|
|
} from './replay-draft';
|
|
import './browser-transform-workspace.css';
|
|
|
|
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
|
|
|
interface BrowserTransformWorkspaceProps {
|
|
tab?: ActiveTabInfo;
|
|
selectedEvent?: BrowserRecordingEvent;
|
|
busy: boolean;
|
|
run: RunTask;
|
|
onOpenCapture: () => void;
|
|
suggestion?: BrowserTransformSuggestionSeed;
|
|
}
|
|
|
|
export interface BrowserTransformSuggestionSeed {
|
|
revision: number;
|
|
candidate: BrowserProfileInferenceCandidate;
|
|
callable: BrowserPageCallable;
|
|
profile: BrowserTransformProfile;
|
|
sampleBody?: string;
|
|
sampleLabel?: string;
|
|
}
|
|
|
|
const BUILTINS: Array<{ value: BrowserTransformBuiltinOperation; label: string }> = [
|
|
{ value: 'value.literal', label: '固定值' },
|
|
{ value: 'json.stringify', label: 'JSON 序列化' },
|
|
{ value: 'json.parse', label: 'JSON 解析' },
|
|
{ value: 'text.toString', label: '转为文本' },
|
|
{ value: 'url.encode', label: 'URL 编码' },
|
|
{ value: 'url.decode', label: 'URL 解码' },
|
|
{ value: 'base64.encode', label: 'Base64 编码' },
|
|
{ value: 'base64.decode', label: 'Base64 解码' },
|
|
{ value: 'hex.encode', label: 'Hex 编码' },
|
|
{ value: 'hex.decode', label: 'Hex 解码' },
|
|
{ value: 'object.pick', label: '选择对象字段' },
|
|
{ value: 'object.compose', label: '组合对象' },
|
|
{ value: 'form.compose', label: '组合表单' },
|
|
];
|
|
|
|
function originOf(url?: string): string {
|
|
try { return url ? new URL(url).origin : ''; } catch { return ''; }
|
|
}
|
|
|
|
function absoluteUrl(value?: string, base?: string): string {
|
|
try { return value ? new URL(value, base).toString() : base || ''; } catch { return value || base || ''; }
|
|
}
|
|
|
|
function uid(prefix: string): string {
|
|
return `${prefix}-${crypto.randomUUID()}`;
|
|
}
|
|
|
|
|
|
type GuidedInputKind = 'body' | 'body-field' | 'text' | 'header-field' | 'query-field' | 'custom';
|
|
|
|
function splitInputPath(path: string): { kind: GuidedInputKind; field: string } {
|
|
if (path === 'body') return { kind: 'body', field: '' };
|
|
if (path === 'text') return { kind: 'text', field: '' };
|
|
if (path.startsWith('body.')) return { kind: 'body-field', field: path.slice(5) };
|
|
if (path.startsWith('headers.')) return { kind: 'header-field', field: path.slice(8) };
|
|
if (path.startsWith('query.')) return { kind: 'query-field', field: path.slice(6) };
|
|
return { kind: 'custom', field: path };
|
|
}
|
|
|
|
function joinInputPath(kind: GuidedInputKind, field: string): string {
|
|
if (kind === 'body') return 'body';
|
|
if (kind === 'text') return 'text';
|
|
if (kind === 'body-field') return `body.${field.trim()}`;
|
|
if (kind === 'header-field') return `headers.${field.trim().toLowerCase()}`;
|
|
if (kind === 'query-field') return `query.${field.trim()}`;
|
|
return field.trim();
|
|
}
|
|
|
|
function outputFieldLabel(kind: GuidedTransformOutputKind): string {
|
|
if (kind === 'json-field') return 'JSON 字段名';
|
|
if (kind === 'form-field') return '表单字段名';
|
|
if (kind === 'header') return 'Header 名称';
|
|
if (kind === 'query') return 'Query 参数名';
|
|
return '';
|
|
}
|
|
|
|
const INPUT_ROLE_LABELS: Record<BrowserPageCallable['inputSlots'][number]['role'], string> = {
|
|
data: '明文数据',
|
|
key: '密钥',
|
|
iv: 'IV',
|
|
algorithm: '算法',
|
|
options: '选项',
|
|
signature: '签名',
|
|
salt: 'Salt',
|
|
nonce: 'Nonce',
|
|
aad: '附加数据',
|
|
unknown: '页面参数',
|
|
};
|
|
|
|
function toInput(profile: BrowserTransformProfile): BrowserTransformProfileInput {
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
enabled: profile.enabled,
|
|
target: { ...profile.target },
|
|
origin: profile.origin,
|
|
match: { methods: [...profile.match.methods], urlPattern: profile.match.urlPattern },
|
|
request: structuredClone(profile.request),
|
|
response: structuredClone(profile.response),
|
|
failMode: 'closed',
|
|
maxConcurrency: profile.maxConcurrency,
|
|
};
|
|
}
|
|
|
|
function profileFingerprint(profile?: BrowserTransformProfileInput): string {
|
|
return profile ? JSON.stringify(profile) : '';
|
|
}
|
|
|
|
function encodeUtf8(value: string): string {
|
|
const bytes = new TextEncoder().encode(value);
|
|
let binary = '';
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
return btoa(binary);
|
|
}
|
|
|
|
function formatSampleBody(value: string): string {
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
return typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed, null, 2) : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function sampleHeaders(value: string): string {
|
|
try {
|
|
JSON.parse(value);
|
|
return '{"Content-Type":"application/json"}';
|
|
} catch {
|
|
return '{"Content-Type":"text/plain; charset=utf-8"}';
|
|
}
|
|
}
|
|
|
|
const DEFAULT_REPLAY_BODY = '{\n "value": "plain"\n}';
|
|
|
|
type ReplayPersistenceState = 'memory' | 'loading' | 'ready' | 'saving' | 'saved' | 'too-large' | 'error';
|
|
|
|
interface PendingReplaySave {
|
|
key: string;
|
|
fingerprint: string;
|
|
input: BrowserTransformReplayDraftInput;
|
|
revision: number;
|
|
timeout: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
function defaultReplayFields(tab?: ActiveTabInfo, selectedEvent?: BrowserRecordingEvent): BrowserTransformReplayDraftFields {
|
|
return {
|
|
method: selectedEvent?.method || 'POST',
|
|
url: absoluteUrl(selectedEvent?.url, tab?.url),
|
|
headers: '{"Content-Type":"application/json"}',
|
|
body: DEFAULT_REPLAY_BODY,
|
|
};
|
|
}
|
|
|
|
function replayFieldsFingerprint(fields: BrowserTransformReplayDraftFields): string {
|
|
return JSON.stringify(fields);
|
|
}
|
|
|
|
function replayPersistenceLabel(state: ReplayPersistenceState): string {
|
|
if (state === 'memory') return '保存网关后自动保存';
|
|
if (state === 'loading') return '正在恢复';
|
|
if (state === 'ready') return '本机自动保存';
|
|
if (state === 'saving') return '正在保存';
|
|
if (state === 'saved') return '本机已保存';
|
|
if (state === 'too-large') return '样本过大';
|
|
return '保存失败';
|
|
}
|
|
|
|
function nodeLabel(kind: BrowserTransformPipelineNode['kind']): string {
|
|
if (kind === 'context.read') return '上下文';
|
|
if (kind === 'builtin') return '内置转换';
|
|
if (kind === 'page.call') return '页面函数';
|
|
return '输出';
|
|
}
|
|
|
|
function callableKindLabel(callable: BrowserPageCallable): string {
|
|
if (callable.kind === 'recorded-call') return '录制调用';
|
|
if (callable.kind === 'business-closure') return '业务闭包';
|
|
if (callable.kind === 'request-transaction') return '请求事务';
|
|
return '全局函数';
|
|
}
|
|
|
|
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
|
|
if (node.kind === 'builtin') return node.inputs;
|
|
if (node.kind === 'page.call') return node.arguments;
|
|
if (node.kind === 'output.write') return [node.source];
|
|
return [];
|
|
}
|
|
|
|
export function BrowserTransformWorkspace({ tab, selectedEvent, busy, run, onOpenCapture, suggestion }: BrowserTransformWorkspaceProps) {
|
|
const [profiles, setProfiles] = useState<BrowserTransformProfile[]>([]);
|
|
const [callables, setCallables] = useState<BrowserPageCallable[]>([]);
|
|
const [selectedProfileId, setSelectedProfileId] = useState('');
|
|
const [draft, setDraft] = useState<BrowserTransformProfileInput>();
|
|
const [directionName, setDirectionName] = useState<BrowserTransformDirectionName>('request');
|
|
const [loadError, setLoadError] = useState('');
|
|
const [testMethod, setTestMethod] = useState('POST');
|
|
const [testUrl, setTestUrl] = useState('');
|
|
const [testHeaders, setTestHeaders] = useState('{"Content-Type":"application/json"}');
|
|
const [testBody, setTestBody] = useState(DEFAULT_REPLAY_BODY);
|
|
const [testSample, setTestSample] = useState<{ body: string; label: string }>();
|
|
const [testResult, setTestResult] = useState<BrowserTransformExecution>();
|
|
const [testError, setTestError] = useState('');
|
|
const [replayPersistence, setReplayPersistence] = useState<ReplayPersistenceState>('memory');
|
|
const [replayStorageError, setReplayStorageError] = useState('');
|
|
const [replayLoadedKey, setReplayLoadedKey] = useState('');
|
|
const [editorMode, setEditorMode] = useState<'guided' | 'advanced'>('guided');
|
|
const [confirmDeleteCallableId, setConfirmDeleteCallableId] = useState('');
|
|
const handledSuggestion = useRef(0);
|
|
const replayLoadRevision = useRef(0);
|
|
const replaySaveRevision = useRef(0);
|
|
const replayBaselineFingerprint = useRef('');
|
|
const replayStablePersistence = useRef<ReplayPersistenceState>('memory');
|
|
const pendingReplaySeed = useRef<{ key: string; fields: BrowserTransformReplayDraftFields } | undefined>(undefined);
|
|
const pendingReplaySave = useRef<PendingReplaySave | undefined>(undefined);
|
|
|
|
const replayProfileId = draft?.id || '';
|
|
const replayOrigin = draft?.origin || '';
|
|
const replayKey = replayProfileId ? `${replayProfileId}:${directionName}` : '';
|
|
const replayActiveKey = useRef(replayKey);
|
|
replayActiveKey.current = replayKey;
|
|
const workspaceMounted = useRef(false);
|
|
const replayFields = useMemo<BrowserTransformReplayDraftFields>(() => ({
|
|
method: testMethod,
|
|
url: testUrl,
|
|
headers: testHeaders,
|
|
body: testBody,
|
|
sample: testSample,
|
|
}), [testBody, testHeaders, testMethod, testSample, testUrl]);
|
|
const replayFingerprint = useMemo(() => replayFieldsFingerprint(replayFields), [replayFields]);
|
|
|
|
const applyReplayFields = useCallback((fields: BrowserTransformReplayDraftFields) => {
|
|
setTestMethod(fields.method);
|
|
setTestUrl(fields.url);
|
|
setTestHeaders(fields.headers);
|
|
setTestBody(fields.body);
|
|
setTestSample(fields.sample);
|
|
setTestResult(undefined);
|
|
setTestError('');
|
|
}, []);
|
|
|
|
const discardPendingReplaySave = useCallback(() => {
|
|
replaySaveRevision.current += 1;
|
|
if (pendingReplaySave.current) clearTimeout(pendingReplaySave.current.timeout);
|
|
pendingReplaySave.current = undefined;
|
|
}, []);
|
|
|
|
const flushPendingReplaySave = useCallback(() => {
|
|
const pending = pendingReplaySave.current;
|
|
if (!pending) return;
|
|
clearTimeout(pending.timeout);
|
|
pendingReplaySave.current = undefined;
|
|
replaySaveRevision.current += 1;
|
|
void saveBrowserTransformReplayDraft(pending.input).catch(() => undefined);
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const target = tab ? { tabId: tab.id, frameId: 0 } : undefined;
|
|
const [nextProfiles, nextCallables] = await Promise.all([
|
|
request('transform.profile.list', target || {}),
|
|
target ? request('callable.list', target).catch(() => []) : Promise.resolve([]),
|
|
]);
|
|
setProfiles(nextProfiles);
|
|
setCallables(nextCallables);
|
|
setLoadError('');
|
|
setSelectedProfileId((current) => nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id || '');
|
|
} catch (error) {
|
|
setLoadError(errorMessage(error));
|
|
}
|
|
}, [tab]);
|
|
|
|
useEffect(() => {
|
|
workspaceMounted.current = true;
|
|
return () => { workspaceMounted.current = false; };
|
|
}, []);
|
|
useEffect(() => { void load(); }, [load]);
|
|
useEffect(() => {
|
|
const selected = profiles.find((profile) => profile.id === selectedProfileId);
|
|
if (selected) {
|
|
setDraft(toInput(selected));
|
|
const selectedDirection = selected.request.enabled ? selected.request : selected.response;
|
|
setEditorMode(parseGuidedTransform(selectedDirection, callables) ? 'guided' : 'advanced');
|
|
}
|
|
}, [callables, profiles, selectedProfileId]);
|
|
useEffect(() => {
|
|
const revision = ++replayLoadRevision.current;
|
|
discardPendingReplaySave();
|
|
setReplayLoadedKey('');
|
|
setReplayStorageError('');
|
|
const fallback = defaultReplayFields(tab, selectedEvent);
|
|
if (!replayProfileId) {
|
|
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
|
replayStablePersistence.current = 'memory';
|
|
applyReplayFields(fallback);
|
|
setReplayPersistence('memory');
|
|
return;
|
|
}
|
|
const seed = pendingReplaySeed.current?.key === replayKey ? pendingReplaySeed.current.fields : undefined;
|
|
if (seed) {
|
|
pendingReplaySeed.current = undefined;
|
|
replayBaselineFingerprint.current = '';
|
|
replayStablePersistence.current = 'ready';
|
|
applyReplayFields(seed);
|
|
setReplayPersistence('ready');
|
|
setReplayLoadedKey(replayKey);
|
|
return;
|
|
}
|
|
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
|
replayStablePersistence.current = 'ready';
|
|
applyReplayFields(fallback);
|
|
setReplayPersistence('loading');
|
|
void getBrowserTransformReplayDraft(replayProfileId, directionName, replayOrigin)
|
|
.then((stored) => {
|
|
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
|
|
const fields = stored ? {
|
|
method: stored.method,
|
|
url: stored.url,
|
|
headers: stored.headers,
|
|
body: stored.body,
|
|
sample: stored.sample,
|
|
} : fallback;
|
|
replayBaselineFingerprint.current = replayFieldsFingerprint(fields);
|
|
replayStablePersistence.current = stored ? 'saved' : 'ready';
|
|
applyReplayFields(fields);
|
|
setReplayPersistence(stored ? 'saved' : 'ready');
|
|
setReplayLoadedKey(replayKey);
|
|
})
|
|
.catch((error) => {
|
|
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
|
|
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
|
replayStablePersistence.current = 'error';
|
|
setReplayStorageError(`无法恢复本机回放草稿:${errorMessage(error)}`);
|
|
setReplayPersistence('error');
|
|
setReplayLoadedKey(replayKey);
|
|
});
|
|
return () => { replayLoadRevision.current += 1; };
|
|
// Replay defaults are captured only when the profile/direction changes.
|
|
// Navigation and new recording selections must not overwrite an edited draft.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [replayKey]);
|
|
useEffect(() => () => flushPendingReplaySave(), [flushPendingReplaySave, replayKey]);
|
|
useEffect(() => {
|
|
if (!replayProfileId || replayLoadedKey !== replayKey) return;
|
|
if (replayFingerprint === replayBaselineFingerprint.current) {
|
|
pendingReplaySave.current = undefined;
|
|
setReplayPersistence(replayStablePersistence.current);
|
|
return;
|
|
}
|
|
const revision = ++replaySaveRevision.current;
|
|
setReplayPersistence('saving');
|
|
setReplayStorageError('');
|
|
const input: BrowserTransformReplayDraftInput = {
|
|
...structuredClone(replayFields),
|
|
profileId: replayProfileId,
|
|
direction: directionName,
|
|
origin: replayOrigin,
|
|
};
|
|
const pending: PendingReplaySave = {
|
|
key: replayKey,
|
|
fingerprint: replayFingerprint,
|
|
input,
|
|
revision,
|
|
timeout: setTimeout(() => {
|
|
if (pendingReplaySave.current === pending) pendingReplaySave.current = undefined;
|
|
void saveBrowserTransformReplayDraft(input)
|
|
.then((result) => {
|
|
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
|
|
replayBaselineFingerprint.current = pending.fingerprint;
|
|
replayStablePersistence.current = result.status === 'saved' ? 'saved' : 'too-large';
|
|
setReplayPersistence(replayStablePersistence.current);
|
|
})
|
|
.catch((error) => {
|
|
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
|
|
setReplayStorageError(`无法保存本机回放草稿:${errorMessage(error)}`);
|
|
setReplayPersistence('error');
|
|
});
|
|
}, 350),
|
|
};
|
|
pendingReplaySave.current = pending;
|
|
return () => clearTimeout(pending.timeout);
|
|
}, [directionName, replayFields, replayFingerprint, replayKey, replayLoadedKey, replayOrigin, replayProfileId]);
|
|
useEffect(() => {
|
|
if (!suggestion || !tab || handledSuggestion.current >= suggestion.revision) return;
|
|
handledSuggestion.current = suggestion.revision;
|
|
replayLoadRevision.current += 1;
|
|
discardPendingReplaySave();
|
|
const sampleBody = suggestion.sampleBody ? formatSampleBody(suggestion.sampleBody) : undefined;
|
|
const fields: BrowserTransformReplayDraftFields = {
|
|
...defaultReplayFields(tab, selectedEvent),
|
|
method: suggestion.candidate.request.method || 'POST',
|
|
url: absoluteUrl(suggestion.candidate.request.url, tab.url),
|
|
headers: suggestion.sampleBody ? sampleHeaders(suggestion.sampleBody) : '{"Content-Type":"application/json"}',
|
|
body: sampleBody || DEFAULT_REPLAY_BODY,
|
|
sample: sampleBody ? { body: sampleBody, label: suggestion.sampleLabel || '录制短时样本' } : undefined,
|
|
};
|
|
const seedKey = `${suggestion.profile.id}:request`;
|
|
pendingReplaySeed.current = { key: seedKey, fields };
|
|
replayBaselineFingerprint.current = '';
|
|
replayStablePersistence.current = 'ready';
|
|
applyReplayFields(fields);
|
|
setReplayLoadedKey(seedKey);
|
|
setReplayPersistence('ready');
|
|
setReplayStorageError('');
|
|
setCallables((current) => [...current.filter((item) => item.id !== suggestion.callable.id), suggestion.callable]);
|
|
setProfiles((current) => [suggestion.profile, ...current.filter((item) => item.id !== suggestion.profile.id)]);
|
|
setSelectedProfileId(suggestion.profile.id);
|
|
setDraft(toInput(suggestion.profile));
|
|
setDirectionName('request');
|
|
setEditorMode('guided');
|
|
setTestResult(undefined);
|
|
setTestError('');
|
|
}, [applyReplayFields, discardPendingReplaySave, selectedEvent, suggestion, tab]);
|
|
|
|
const savedProfile = profiles.find((profile) => profile.id === selectedProfileId);
|
|
const dirty = Boolean(draft && profileFingerprint(draft) !== profileFingerprint(savedProfile ? toInput(savedProfile) : undefined));
|
|
const callableIds = useMemo(() => new Set(callables.map((callable) => callable.id)), [callables]);
|
|
const referencedCallableIds = useMemo(() => draft ? [draft.request, draft.response]
|
|
.flatMap((direction) => direction.enabled ? direction.nodes : [])
|
|
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
|
|
.map((node) => node.callableId) : [], [draft]);
|
|
const bindingReady = Boolean(draft && originOf(tab?.url) === draft.origin && referencedCallableIds.every((id) => callableIds.has(id)));
|
|
const callableReferences = useMemo(() => {
|
|
const references = new Map<string, number>();
|
|
for (const profile of profiles) {
|
|
for (const node of [profile.request, profile.response].flatMap((item) => item.enabled ? item.nodes : [])) {
|
|
if (node.kind !== 'page.call') continue;
|
|
references.set(node.callableId, (references.get(node.callableId) || 0) + 1);
|
|
}
|
|
}
|
|
return references;
|
|
}, [profiles]);
|
|
const direction = draft?.[directionName];
|
|
const guide = useMemo(() => direction ? parseGuidedTransform(direction, callables) : undefined, [callables, direction]);
|
|
const guidedCallable = callables.find((callable) => callable.id === guide?.callableId);
|
|
const guidedValid = Boolean(guide && guide.callableId
|
|
&& guide.inputPaths.every((path) => path.trim())
|
|
&& (guide.outputKind === 'body' || guide.outputField.trim()));
|
|
const replayLoading = replayPersistence === 'loading';
|
|
const replayPersistenceTitle = replayStorageError
|
|
|| (replayPersistence === 'too-large'
|
|
? '当前草稿超过 256 KiB,仅保留在本次页面中;已移除旧的本机副本,避免下次恢复过期内容。'
|
|
: replayPersistence === 'memory'
|
|
? '保存明文网关后,回放输入会仅保存在当前浏览器中。'
|
|
: '仅保存在当前浏览器,不会进入明文网关导出、Bridge、Yak 引擎或 AI 上下文。');
|
|
|
|
const selectProfile = (profile: BrowserTransformProfile) => {
|
|
setSelectedProfileId(profile.id);
|
|
setDraft(toInput(profile));
|
|
setDirectionName(profile.request.enabled ? 'request' : 'response');
|
|
setEditorMode(parseGuidedTransform(profile.request.enabled ? profile.request : profile.response, callables) ? 'guided' : 'advanced');
|
|
setTestResult(undefined);
|
|
};
|
|
|
|
const create = () => {
|
|
if (!tab) return;
|
|
setSelectedProfileId('');
|
|
setDraft(createBrowserTransformProfileInput(tab, selectedEvent, callables[0]));
|
|
setDirectionName('request');
|
|
setEditorMode('guided');
|
|
setTestResult(undefined);
|
|
};
|
|
|
|
const patchDirection = (patcher: (value: BrowserTransformDirection) => BrowserTransformDirection) => {
|
|
setDraft((current) => current ? { ...current, [directionName]: patcher(current[directionName]) } : current);
|
|
setTestResult(undefined);
|
|
};
|
|
|
|
const patchNode = (id: string, patch: Partial<BrowserTransformPipelineNode>) => patchDirection((current) => ({
|
|
...current,
|
|
nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } as BrowserTransformPipelineNode : node),
|
|
}));
|
|
|
|
const patchGuide = (next: GuidedTransformDraft) => {
|
|
const callable = callables.find((item) => item.id === next.callableId);
|
|
patchDirection(() => compileGuidedTransform(next, callable));
|
|
};
|
|
|
|
const selectGuidedCallable = (callableId: string) => {
|
|
if (!guide) return;
|
|
const callable = callables.find((item) => item.id === callableId);
|
|
const defaults = defaultGuidedTransform(callable, { outputKind: guide.outputKind, outputField: guide.outputField });
|
|
patchGuide({
|
|
...guide,
|
|
callableId,
|
|
inputPaths: defaults.inputPaths.map((path, index) => guide.inputPaths[index] || path),
|
|
});
|
|
};
|
|
|
|
const patchGuideInput = (index: number, path: string) => {
|
|
if (!guide) return;
|
|
patchGuide({ ...guide, inputPaths: guide.inputPaths.map((item, itemIndex) => itemIndex === index ? path : item) });
|
|
};
|
|
|
|
const addNode = (kind: BrowserTransformPipelineNode['kind']) => patchDirection((current) => {
|
|
const previous = current.nodes.at(-1);
|
|
const reference = previous ? { nodeId: previous.id } : { nodeId: '' };
|
|
let node: BrowserTransformPipelineNode;
|
|
if (kind === 'context.read') node = { id: uid('context'), name: '读取上下文', kind, path: 'body' };
|
|
else if (kind === 'builtin') node = { id: uid('builtin'), name: '转换数据', kind, operation: 'json.stringify', inputs: previous ? [reference] : [] };
|
|
else if (kind === 'page.call') node = { id: uid('call'), name: callables[0]?.name || '调用页面函数', kind, callableId: callables[0]?.id || '', arguments: previous ? [reference] : [] };
|
|
else node = { id: uid('output'), name: '写入输出', kind, destination: 'body', source: reference, encoding: 'auto' };
|
|
return { ...current, nodes: [...current.nodes, node] };
|
|
});
|
|
|
|
const patchReferences = (node: BrowserTransformPipelineNode, references: BrowserTransformNodeReference[]) => {
|
|
if (node.kind === 'builtin') patchNode(node.id, { inputs: references });
|
|
else if (node.kind === 'page.call') patchNode(node.id, { arguments: references });
|
|
};
|
|
|
|
const save = () => run(async () => {
|
|
if (!draft) return;
|
|
const replaySeed = structuredClone(replayFields);
|
|
const wasNew = !draft.id;
|
|
const profile = await request('transform.profile.save', draft);
|
|
if (wasNew) {
|
|
const seedKey = `${profile.id}:${directionName}`;
|
|
pendingReplaySeed.current = { key: seedKey, fields: replaySeed };
|
|
replayBaselineFingerprint.current = '';
|
|
replayStablePersistence.current = 'ready';
|
|
setReplayLoadedKey(seedKey);
|
|
setReplayPersistence('ready');
|
|
}
|
|
setProfiles((current) => [profile, ...current.filter((item) => item.id !== profile.id)]);
|
|
setSelectedProfileId(profile.id);
|
|
setDraft(toInput(profile));
|
|
}, 'Pipeline v2 配置已保存');
|
|
|
|
const remove = () => run(async () => {
|
|
if (!draft?.id) { setDraft(undefined); return; }
|
|
const removedId = draft.id;
|
|
discardPendingReplaySave();
|
|
setReplayLoadedKey('');
|
|
const remaining = await request('transform.profile.delete', { id: removedId });
|
|
// The background service also removes these keys. Repeating the local
|
|
// cleanup here serializes behind any Options-page write already in flight.
|
|
await deleteBrowserTransformReplayDrafts(removedId).catch(() => undefined);
|
|
setProfiles(remaining);
|
|
setSelectedProfileId(remaining[0]?.id || '');
|
|
setDraft(remaining[0] ? toInput(remaining[0]) : undefined);
|
|
}, '明文网关配置已删除');
|
|
|
|
const clearReplay = () => run(async () => {
|
|
if (!draft?.id) return;
|
|
const profileId = draft.id;
|
|
const key = replayKey;
|
|
replayLoadRevision.current += 1;
|
|
discardPendingReplaySave();
|
|
setReplayLoadedKey('');
|
|
await clearBrowserTransformReplayDraft(profileId, directionName);
|
|
if (replayActiveKey.current !== key) return;
|
|
const fallback = defaultReplayFields(tab, selectedEvent);
|
|
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
|
replayStablePersistence.current = 'ready';
|
|
applyReplayFields(fallback);
|
|
setReplayStorageError('');
|
|
setReplayPersistence('ready');
|
|
setReplayLoadedKey(key);
|
|
}, '当前方向的本机回放草稿已清空');
|
|
|
|
const deleteCallable = (callable: BrowserPageCallable) => run(async () => {
|
|
const remaining = await request('callable.delete', { ...callable.target, callableId: callable.id });
|
|
setCallables(remaining);
|
|
setConfirmDeleteCallableId('');
|
|
setTestResult(undefined);
|
|
}, callableReferences.get(callable.id) ? '页面函数已删除,引用它的明文网关需要重新绑定' : '页面函数已删除');
|
|
|
|
const execute = async () => {
|
|
if (!draft?.id || dirty) { setTestError('请先保存当前 Pipeline'); return; }
|
|
setTestError('');
|
|
setTestResult(undefined);
|
|
try {
|
|
const parsed = JSON.parse(testHeaders) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Header 必须是 JSON 对象');
|
|
const headers = Object.entries(parsed as Record<string, unknown>).map(([name, value]) => ({ name, value: String(value) }));
|
|
setTestResult(await request('transform.execute', {
|
|
profileId: draft.id,
|
|
direction: directionName,
|
|
packet: { method: testMethod.toUpperCase(), url: testUrl, statusCode: directionName === 'response' ? 200 : undefined, headers, bodyBase64: encodeUtf8(testBody) },
|
|
}));
|
|
} catch (error) { setTestError(errorMessage(error)); }
|
|
};
|
|
|
|
return <div className="transform-workbench">
|
|
<aside className="transform-profiles">
|
|
<header><div><strong>明文网关</strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={create}><Plus size={15} /></Button></header>
|
|
<div className="transform-profile-list">
|
|
{profiles.map((profile) => {
|
|
const ready = originOf(tab?.url) === profile.origin && [profile.request, profile.response]
|
|
.flatMap((item) => item.enabled ? item.nodes : [])
|
|
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
|
|
.every((node) => callableIds.has(node.callableId));
|
|
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => selectProfile(profile)}>
|
|
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
|
|
<span><strong>{profile.name}</strong><small>{profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
|
|
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
|
|
</button>;
|
|
})}
|
|
{!profiles.length && <div className="transform-profile-empty"><FileKey2 size={20} /><strong>没有 Pipeline</strong><Button size="sm" variant="primary" disabled={!tab} onClick={create}><CirclePlus size={14} />新建</Button></div>}
|
|
</div>
|
|
<footer>
|
|
<details className="transform-callable-menu">
|
|
<summary className={callables.length ? 'is-ready' : ''}><i />{callables.length} 个页面函数<ChevronDown size={12} /></summary>
|
|
<div className="transform-callable-popover">
|
|
<header><div><strong>当前文档页面函数</strong><span>页面刷新或导航后自动失效</span></div><em>{callables.length}</em></header>
|
|
{!callables.length ? <div className="transform-callable-empty"><Code2 size={17} /><span>还没有可管理的页面函数</span></div> : <div className="transform-callable-list">{callables.map((callable) => {
|
|
const referenceCount = callableReferences.get(callable.id) || 0;
|
|
const confirming = confirmDeleteCallableId === callable.id;
|
|
return <section key={callable.id}>
|
|
<div className="transform-callable-row"><span><strong>{callable.name}</strong><small>{callableKindLabel(callable)} · {callable.algorithm || callable.operation}</small></span><Button size="icon" variant="ghost" aria-label={`删除 ${callable.name}`} title="删除页面函数" disabled={busy} onClick={() => setConfirmDeleteCallableId(callable.id)}><Trash2 size={13} /></Button></div>
|
|
{confirming && <div className="transform-callable-confirm"><span>{referenceCount ? `${referenceCount} 个网关节点正在引用,删除后会显示“页面函数缺失”。` : '这个页面函数将从当前文档中移除。'}</span><div><Button size="sm" variant="ghost" onClick={() => setConfirmDeleteCallableId('')}>取消</Button><Button size="sm" variant="danger" disabled={busy} onClick={() => void deleteCallable(callable)}>确认删除</Button></div></div>}
|
|
</section>;
|
|
})}</div>}
|
|
</div>
|
|
</details>
|
|
<Button size="icon" variant="ghost" aria-label="刷新页面绑定" title="刷新页面绑定" onClick={() => void load()}><RefreshCw size={14} /></Button>
|
|
</footer>
|
|
</aside>
|
|
|
|
<main className="transform-editor">
|
|
{!draft ? <div className="transform-editor-empty"><Link2 size={24} /><strong>建立明文与线上报文的转换链路</strong>{callables.length ? <Button variant="primary" onClick={create}><CirclePlus size={14} />新建 Pipeline</Button> : <Button variant="primary" onClick={onOpenCapture}><Code2 size={14} />先捕获页面函数</Button>}</div> : <>
|
|
<header className="transform-editor-head"><div><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /><span>{draft.origin} · document 生命周期</span></div><label><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} />启用</label></header>
|
|
<div className="transform-route">
|
|
<label><span>HTTP 方法</span><input value={draft.match.methods.join(', ')} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, methods: event.target.value.split(',').map((item) => item.trim().toUpperCase()).filter(Boolean) } })} /></label>
|
|
<label><span>URL 模式</span><input value={draft.match.urlPattern} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, urlPattern: event.target.value } })} /></label>
|
|
<label><span>并发</span><input type="number" min={1} max={8} value={draft.maxConcurrency} onChange={(event) => setDraft({ ...draft, maxConcurrency: Number(event.target.value) })} /></label>
|
|
</div>
|
|
<div className="transform-direction-tabs">
|
|
{(['request', 'response'] as const).map((name) => <button key={name} className={directionName === name ? 'is-selected' : ''} onClick={() => setDirectionName(name)}>{name === 'request' ? '请求加密' : '响应解密'}<i className={draft[name].enabled ? 'is-enabled' : ''}>{draft[name].enabled ? `${draft[name].nodes.length} 节点` : '关闭'}</i></button>)}
|
|
</div>
|
|
{direction && <div className="transform-pipeline-editor">
|
|
<div className="transform-direction-state"><div><strong>{directionName === 'request' ? '明文 → 线上请求' : '线上响应 → 明文'}</strong><span>{editorMode === 'guided' ? '确认三个业务选择,底层 Pipeline 自动生成' : '直接编辑有序 DAG 与节点引用'}</span></div><Switch checked={direction.enabled} onCheckedChange={(enabled) => patchDirection((current) => ({ ...current, enabled }))} /></div>
|
|
<div className="transform-editor-mode" role="tablist" aria-label="Pipeline 编辑方式">
|
|
<button type="button" className={editorMode === 'guided' ? 'is-selected' : ''} onClick={() => setEditorMode('guided')}><Sparkles size={13} />引导配置</button>
|
|
<button type="button" className={editorMode === 'advanced' ? 'is-selected' : ''} onClick={() => setEditorMode('advanced')}><Code2 size={13} />高级 Pipeline</button>
|
|
</div>
|
|
|
|
{editorMode === 'guided' && (!guide ? <div className="transform-guide-empty">
|
|
<Sparkles size={20} />
|
|
<div><strong>{direction.nodes.length ? '这条 Pipeline 包含高级结构' : '选择页面函数后自动生成'}</strong><span>{direction.nodes.length ? '高级结构不会被静默改写;可继续使用高级编辑,或明确替换成三步引导流程。' : '无需添加节点、引用或内置转换。'}</span></div>
|
|
<Button size="sm" variant="primary" disabled={!callables.length} onClick={() => patchGuide(defaultGuidedTransform(callables[0]))}>{direction.nodes.length ? '替换为引导流程' : '开始配置'}</Button>
|
|
</div> : guide && <div className="transform-guide">
|
|
<div className="transform-guide-flow">
|
|
<span>逻辑明文</span><ArrowRight size={13} /><strong>{guidedCallable?.name || '选择页面函数'}</strong><ArrowRight size={13} /><span>{guidedOutputDescription(guide)}</span>
|
|
</div>
|
|
|
|
<section className="transform-guide-step">
|
|
<span className="transform-guide-step__index">1</span>
|
|
<div className="transform-guide-step__body">
|
|
<header><div><strong>明文从哪里来</strong><span>通常选择整个逻辑 Body;多参数函数会逐项显示。</span></div><FileInput size={15} /></header>
|
|
<div className="transform-guide-inputs">
|
|
{guide.inputPaths.map((path, index) => {
|
|
const source = splitInputPath(path);
|
|
const slot = guidedCallable?.inputSlots.filter((item) => !item.retained)[index];
|
|
const needsField = !['body', 'text'].includes(source.kind);
|
|
return <div key={`${guide.callableId}:${index}`}>
|
|
<label><span>{slot ? `${slot.name} · ${INPUT_ROLE_LABELS[slot.role]}` : `参数 ${index + 1}`}</span><select value={source.kind} onChange={(event) => {
|
|
const kind = event.target.value as GuidedInputKind;
|
|
const defaultField = kind === 'body-field' ? 'value' : kind === 'header-field' ? 'authorization' : kind === 'query-field' ? 'value' : kind === 'custom' ? 'body' : '';
|
|
patchGuideInput(index, joinInputPath(kind, defaultField));
|
|
}}><option value="body">整个逻辑 Body</option><option value="body-field">Body 中的字段</option><option value="text">原始 Body 文本</option><option value="header-field">Header 字段</option><option value="query-field">Query 参数</option><option value="custom">高级上下文路径</option></select></label>
|
|
{needsField && <label><span>{source.kind === 'custom' ? '上下文路径' : '字段名'}</span><input value={source.field} onChange={(event) => patchGuideInput(index, joinInputPath(source.kind, event.target.value))} placeholder={source.kind === 'custom' ? 'body.account.id' : 'password'} /></label>}
|
|
</div>;
|
|
})}
|
|
{!guide.inputPaths.length && <div className="transform-guide-note">这个页面函数不需要外部输入,将直接使用页面内保留的环境。</div>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="transform-guide-step">
|
|
<span className="transform-guide-step__index">2</span>
|
|
<div className="transform-guide-step__body">
|
|
<header><div><strong>交给哪个页面函数</strong><span>函数在当前页面文档中执行,Key、IV 与闭包值不会离开页面。</span></div><Code2 size={15} /></header>
|
|
<label className="transform-guide-callable"><span>页面能力</span><select value={guide.callableId} onChange={(event) => selectGuidedCallable(event.target.value)}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>
|
|
{guidedCallable && <div className="transform-guide-callable-meta"><span>{callableKindLabel(guidedCallable)}</span><strong>{guidedCallable.algorithm || guidedCallable.operation}</strong><em>{guidedCallable.inputSlots.filter((slot) => !slot.retained).length} 个明文参数</em></div>}
|
|
<details className="transform-guide-result"><summary>函数返回的是对象,需要取其中一个字段</summary><label><span>返回字段路径</span><input value={guide.resultPath || ''} onChange={(event) => patchGuide({ ...guide, resultPath: event.target.value || undefined })} placeholder="例如 encryptedData;留空使用完整返回值" /></label></details>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="transform-guide-step">
|
|
<span className="transform-guide-step__index">3</span>
|
|
<div className="transform-guide-step__body">
|
|
<header><div><strong>线上请求写到哪里</strong><span>选择报文形态即可,字段组合与节点引用由插件生成。</span></div><ArrowDown size={15} /></header>
|
|
<div className="transform-guide-output">
|
|
<label><span>输出形态</span><select value={guide.outputKind} onChange={(event) => {
|
|
const outputKind = event.target.value as GuidedTransformOutputKind;
|
|
const outputField = outputKind === 'body' ? '' : guide.outputField || (outputKind === 'header' ? 'X-Sign' : outputKind === 'query' ? 'signature' : 'encryptedData');
|
|
patchGuide({ ...guide, outputKind, outputField, setFormContentType: outputKind === 'form-field' });
|
|
}}><option value="body">替换整个 Body</option><option value="json-field">写入 JSON 字段</option><option value="form-field">写入表单字段</option><option value="header">写入 Header</option><option value="query">写入 Query 参数</option></select></label>
|
|
{guide.outputKind !== 'body' && <label><span>{outputFieldLabel(guide.outputKind)}</span><input value={guide.outputField} onChange={(event) => patchGuide({ ...guide, outputField: event.target.value })} placeholder={guide.outputKind === 'form-field' ? 'encryptedData' : guide.outputKind === 'header' ? 'X-Sign' : 'signature'} /></label>}
|
|
</div>
|
|
{guide.outputKind === 'form-field' && <label className="transform-guide-content-type"><Switch checked={guide.setFormContentType} onCheckedChange={(setFormContentType) => patchGuide({ ...guide, setFormContentType })} /><span><strong>自动设置表单 Content-Type</strong><small>生成 application/x-www-form-urlencoded,无需再添加固定值和 Header 节点。</small></span></label>}
|
|
<div className={`transform-guide-ready ${guidedValid ? 'is-ready' : ''}`}><CheckCircle2 size={14} /><span>{guidedValid ? `将自动生成 ${direction.nodes.length} 个底层节点` : '补全页面函数、输入来源和输出字段后即可保存'}</span></div>
|
|
</div>
|
|
</section>
|
|
</div>)}
|
|
|
|
{editorMode === 'advanced' && <>
|
|
<div className="transform-advanced-notice"><Code2 size={14} /><span><strong>高级 Pipeline</strong>节点、引用和白名单操作会直接影响线上报文;常规加解密场景建议使用引导配置。</span></div>
|
|
<div className="transform-node-list">
|
|
{direction.nodes.map((node, index) => {
|
|
const available = direction.nodes.slice(0, index);
|
|
const references = referencesOf(node);
|
|
return <section className="transform-node" key={node.id}>
|
|
<div className="transform-node-index"><span>{index + 1}</span>{index < direction.nodes.length - 1 && <i />}</div>
|
|
<div className="transform-node-fields">
|
|
<header><em>{nodeLabel(node.kind)}</em><input value={node.name} onChange={(event) => patchNode(node.id, { name: event.target.value })} /><Button size="icon" variant="ghost" aria-label="删除节点" title="删除节点" onClick={() => patchDirection((current) => ({ ...current, nodes: current.nodes.filter((item) => item.id !== node.id) }))}><Trash2 size={13} /></Button></header>
|
|
{node.kind === 'context.read' && <label><span>上下文路径</span><input value={node.path} onChange={(event) => patchNode(node.id, { path: event.target.value })} placeholder="body.password" /></label>}
|
|
{node.kind === 'builtin' && <><label><span>白名单操作</span><select value={node.operation} onChange={(event) => patchNode(node.id, { operation: event.target.value as BrowserTransformBuiltinOperation })}>{BUILTINS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>{node.operation === 'value.literal' && <label><span>固定值</span><input value={typeof node.options?.value === 'string' ? node.options.value : ''} onChange={(event) => patchNode(node.id, { options: { value: event.target.value } })} /></label>}{['form.compose', 'object.compose'].includes(node.operation) && <label><span>字段名 · 按输入顺序</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="encryptedData, signature" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label>}{node.operation === 'object.pick' && <><label><span>读取路径</span><input value={Array.isArray(node.options?.paths) ? node.options.paths.join(', ') : ''} placeholder="account.id, profile.name" onChange={(event) => patchNode(node.id, { options: { ...node.options, paths: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label><label><span>输出字段名</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="accountId, name" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label></>}</>}
|
|
{node.kind === 'page.call' && <label><span>页面函数</span><select value={node.callableId} onChange={(event) => patchNode(node.id, { callableId: event.target.value })}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>}
|
|
{(node.kind === 'page.call' || (node.kind === 'builtin' && node.operation !== 'value.literal')) && <div className="transform-node-references"><span>输入引用</span>{references.map((reference, referenceIndex) => <div key={`${node.id}:${referenceIndex}`}><select value={reference.nodeId} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, nodeId: event.target.value } : item))}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select><input value={reference.path || ''} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, path: event.target.value || undefined } : item))} placeholder="可选子路径" /><Button size="icon" variant="ghost" aria-label="删除输入引用" onClick={() => patchReferences(node, references.filter((_, itemIndex) => itemIndex !== referenceIndex))}><Trash2 size={12} /></Button></div>)}<Button size="sm" variant="ghost" onClick={() => patchReferences(node, [...references, { nodeId: available.at(-1)?.id || '' }])}><Plus size={12} />输入</Button></div>}
|
|
{node.kind === 'output.write' && <div className="transform-output-fields"><label><span>来源节点</span><select value={node.source.nodeId} onChange={(event) => patchNode(node.id, { source: { ...node.source, nodeId: event.target.value } })}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label><label><span>子路径</span><input value={node.source.path || ''} onChange={(event) => patchNode(node.id, { source: { ...node.source, path: event.target.value || undefined } })} placeholder="可选" /></label><label><span>写入目标</span><input value={node.destination} onChange={(event) => patchNode(node.id, { destination: event.target.value })} placeholder="body.encryptedData" /></label><label><span>编码</span><select value={node.encoding} onChange={(event) => patchNode(node.id, { encoding: event.target.value as 'auto' })}><option value="auto">自动</option><option value="text">文本</option><option value="json">JSON</option><option value="base64">Base64</option></select></label></div>}
|
|
</div>
|
|
</section>;
|
|
})}
|
|
</div>
|
|
<div className="transform-node-add"><span>添加节点</span><Button size="sm" variant="ghost" onClick={() => addNode('context.read')}><FileInput size={13} />上下文</Button><Button size="sm" variant="ghost" onClick={() => addNode('builtin')}><Braces size={13} />内置转换</Button><Button size="sm" variant="ghost" onClick={() => addNode('page.call')}><Code2 size={13} />页面函数</Button><Button size="sm" variant="ghost" onClick={() => addNode('output.write')}><ArrowDown size={13} />输出</Button></div>
|
|
</>}
|
|
</div>}
|
|
<footer className="transform-editor-actions"><span className={bindingReady ? 'is-ready' : 'is-stale'}><i />{bindingReady ? '当前页面函数可用' : '页面函数缺失或文档已变化'}</span><Button size="icon" variant="ghost" aria-label="删除配置" title="删除配置" onClick={() => void remove()}><Trash2 size={14} /></Button><Button variant="primary" disabled={busy || !dirty || (editorMode === 'guided' && Boolean(direction?.enabled) && (!guide || !guidedValid))} onClick={() => void save()}><Save size={14} />保存</Button></footer>
|
|
</>}
|
|
</main>
|
|
|
|
<aside className="transform-test">
|
|
<header>
|
|
<div><FlaskConical size={15} /><span><strong>本地回放</strong><small>不发送网络请求</small></span></div>
|
|
<div className="transform-test-header-actions">
|
|
{testResult && <i className="transform-test-duration">{testResult.durationMs.toFixed(1)} ms</i>}
|
|
<span className={`transform-replay-persistence is-${replayPersistence}`} title={replayPersistenceTitle} aria-live="polite"><i />{replayPersistenceLabel(replayPersistence)}</span>
|
|
<Button size="icon" variant="ghost" disabled={!draft?.id || busy || replayLoading} aria-label="清空本机回放草稿" title="清空当前方向的本机回放草稿" onClick={() => void clearReplay()}><Trash2 size={13} /></Button>
|
|
</div>
|
|
</header>
|
|
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={testMethod} onChange={(event) => { setTestMethod(event.target.value); setTestResult(undefined); }} /><input disabled={replayLoading} aria-label="回放请求 URL" value={testUrl} onChange={(event) => { setTestUrl(event.target.value); setTestResult(undefined); }} placeholder="https://example.test/api" /></div></label>
|
|
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={testHeaders} onChange={(event) => { setTestHeaders(event.target.value); setTestResult(undefined); }} /></label>
|
|
<div className="transform-test-body"><div className="transform-test-field-label"><span>Body</span>{testSample && (testBody === testSample.body ? <em title={testSample.label}>短时样本</em> : <button type="button" disabled={replayLoading} onClick={() => { setTestBody(testSample.body); setTestResult(undefined); }}>恢复短时样本</button>)}</div><textarea disabled={replayLoading} aria-label="回放 Body" rows={8} value={testBody} onChange={(event) => { setTestBody(event.target.value); setTestResult(undefined); }} /></div>
|
|
<Button variant="primary" disabled={!draft?.id || dirty || busy || replayLoading || !bindingReady} onClick={() => void execute()}><Play size={14} />执行 Pipeline</Button>
|
|
{(loadError || replayStorageError || testError) && <div className="transform-test-error"><AlertTriangle size={14} />{loadError || replayStorageError || testError}</div>}
|
|
{testResult && <section className="transform-test-result"><header><div><CheckCircle2 size={14} /><strong>转换完成</strong></div><span>{testResult.nodeDurations.length} 节点</span></header><dl><div><dt>输出 URL</dt><dd>{testResult.url}</dd></div><div><dt>Body Base64</dt><dd>{testResult.bodyBase64.slice(0, 64)}{testResult.bodyBase64.length > 64 ? '…' : ''}</dd></div><div><dt>Headers</dt><dd>{testResult.setHeaders.length} 设置 · {testResult.removeHeaders.length} 删除</dd></div></dl><pre>{JSON.stringify(testResult.logicalOutput, null, 2)}</pre><footer>{testResult.nodeDurations.map((node) => <span key={node.nodeId}>{node.nodeId.split('-')[0]} · {node.durationMs.toFixed(1)} ms</span>)}</footer></section>}
|
|
</aside>
|
|
</div>;
|
|
}
|