mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
feat(browser): refine workbench navigation and authorization
This commit is contained in:
@@ -167,7 +167,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
case 'authorization.yakit.open':
|
||||
return ok(await engineBridge.requestEngine(
|
||||
'yakit.browser_authorization.open',
|
||||
{ workspaceId: request.payload.workspaceId },
|
||||
request.payload,
|
||||
));
|
||||
case 'context.capture': {
|
||||
const { tabId, frameId, documentId, ...options } = request.payload;
|
||||
|
||||
@@ -75,6 +75,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.sidebar-brand .product-brand { width: 100%; color: var(--foreground); }
|
||||
.sidebar nav { min-height: 0; padding: 10px 10px 16px; overflow-y: auto; display: grid; gap: 10px; scrollbar-width: thin; }
|
||||
.sidebar-group { display: grid; gap: 2px; }
|
||||
.sidebar-group.is-primary { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-group__label { min-height: 24px; padding: 0 10px; display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; }
|
||||
.sidebar-group__label svg { color: var(--primary); }
|
||||
.sidebar nav button { width: 100%; height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px 1fr 14px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-strong); font-size: var(--text-md); font-weight: 500; text-align: left; cursor: pointer; transition: background-color .14s ease, color .14s ease; }
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import {
|
||||
Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, CloudDownload, Cookie, Copy,
|
||||
Database, Download, Eye, Fingerprint, History, KeyRound, MousePointer2, Network, Play, Power, Radio,
|
||||
RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, Wrench, X,
|
||||
Database, Download, Eye, FileKey2, Fingerprint, History, KeyRound, MousePointer2, Network, Play, Power, Radio,
|
||||
RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, X,
|
||||
} from 'lucide-react';
|
||||
import { ProductBrand, YakitMark } from '@/components/brand/Brand';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -30,37 +30,43 @@ import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import { APPEARANCE_STORAGE_KEY, getAppearance, setThemePreference, type ThemePreference } from '@/platform/storage/appearance';
|
||||
import './App.css';
|
||||
|
||||
type Section = 'overview' | 'authorization' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity';
|
||||
type Section = 'overview' | 'authorization' | 'network' | 'gateway' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'context' | 'engine' | 'activity';
|
||||
const FIREFOX_AMO_BUILD = import.meta.env.FIREFOX && import.meta.env.MODE === 'store';
|
||||
|
||||
const NAVIGATION: Array<{ label: string; icon?: ReactNode; items: Array<{ id: Section; label: string; icon: ReactNode }> }> = [
|
||||
const NAVIGATION: Array<{ label?: string; items: Array<{ id: Section; label: string; icon: ReactNode }> }> = [
|
||||
{
|
||||
label: '工作区',
|
||||
items: [{ id: 'overview', label: '概览', icon: <CircleGauge size={17} /> }],
|
||||
},
|
||||
{
|
||||
label: '安全测试',
|
||||
items: [{ id: 'authorization', label: '越权测试', icon: <Fingerprint size={17} /> }],
|
||||
},
|
||||
{
|
||||
label: '请求与改写',
|
||||
items: [
|
||||
{ id: 'overview', label: '运行概览', icon: <CircleGauge size={17} /> },
|
||||
{ id: 'authorization', label: '授权测试', icon: <Fingerprint size={17} /> },
|
||||
{ id: 'network', label: '请求捕获', icon: <Activity size={17} /> },
|
||||
{ id: 'gateway', label: '明文网关', icon: <FileKey2 size={17} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '网络与流量',
|
||||
label: '代理',
|
||||
items: [
|
||||
{ id: 'proxies', label: '代理出口', icon: <Network size={17} /> },
|
||||
{ id: 'rules', label: '自动切换', icon: <Route size={17} /> },
|
||||
{ id: 'proxies', label: '代理设置', icon: <Network size={17} /> },
|
||||
{ id: 'rules', label: '分流规则', icon: <Route size={17} /> },
|
||||
{ id: 'sources', label: '规则订阅', icon: <CloudDownload size={17} /> },
|
||||
{ id: 'network', label: '网络活动', icon: <Activity size={17} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '常用工具', icon: <Wrench size={13} />,
|
||||
label: '浏览器工具',
|
||||
items: [
|
||||
{ id: 'cookies', label: 'Cookie Editor', icon: <Cookie size={17} /> },
|
||||
{ id: 'user-agent', label: 'UA 快速切换', icon: <UserRoundCog size={17} /> },
|
||||
{ id: 'context', label: '页面上下文', icon: <KeyRound size={17} /> },
|
||||
{ id: 'cookies', label: 'Cookie 管理', icon: <Cookie size={17} /> },
|
||||
{ id: 'user-agent', label: 'User-Agent', icon: <UserRoundCog size={17} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agent 与系统',
|
||||
label: '系统',
|
||||
items: [
|
||||
{ id: 'context', label: '登录态工作区', icon: <KeyRound size={17} /> },
|
||||
{ id: 'engine', label: '引擎连接', icon: <Server size={17} /> },
|
||||
{ id: 'activity', label: '操作记录', icon: <History size={17} /> },
|
||||
],
|
||||
@@ -204,7 +210,7 @@ function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand"><ProductBrand /></div>
|
||||
<nav>{NAVIGATION.map((group) => <div className="sidebar-group" key={group.label}><span className="sidebar-group__label">{group.icon}{group.label}</span>{group.items.map((item) => <button key={item.id} className={section === item.id ? 'active' : ''} onClick={() => navigate(item.id)}>{item.icon}<span>{item.label}</span><ChevronRight size={14} /></button>)}</div>)}</nav>
|
||||
<nav>{NAVIGATION.map((group, index) => <div className={`sidebar-group ${index === 0 ? 'is-primary' : ''}`} key={group.label || 'overview'}>{group.label && <span className="sidebar-group__label">{group.label}</span>}{group.items.map((item) => <button key={item.id} className={section === item.id ? 'active' : ''} onClick={() => navigate(item.id)}>{item.icon}<span>{item.label}</span><ChevronRight size={14} /></button>)}</div>)}</nav>
|
||||
<div className="sidebar-theme">
|
||||
<span>外观</span>
|
||||
<select aria-label="界面主题" value={theme} onChange={(event) => { const next = event.target.value as ThemePreference; setTheme(next); void setThemePreference(next); }}>
|
||||
@@ -239,6 +245,7 @@ function App() {
|
||||
{section === 'cookies' && <CookieEditor key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'user-agent' && <UserAgents state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'gateway' && <GatewayWorkspace key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'context' && <ContextTool key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
|
||||
{section === 'activity' && <ActivityLog run={run} busy={busy} />}
|
||||
@@ -537,13 +544,6 @@ function NetworkActivity({
|
||||
const [captureHeaders, setCaptureHeaders] = useState(false);
|
||||
const [captureBody, setCaptureBody] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const transformShared = bridge.state === 'connected';
|
||||
|
||||
const shareTransform = async () => {
|
||||
if (!tab) throw new Error('请先选择需要使用的页面');
|
||||
if (bridge.state !== 'connected') await request('bridge.connect');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!tab) return;
|
||||
try {
|
||||
@@ -655,12 +655,33 @@ function NetworkActivity({
|
||||
</aside>
|
||||
</div>}
|
||||
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GatewayWorkspace({
|
||||
tab,
|
||||
bridge,
|
||||
run,
|
||||
busy,
|
||||
}: {
|
||||
tab?: ActiveTabInfo;
|
||||
bridge: BridgeStatus;
|
||||
run: (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const shareTransform = async () => {
|
||||
if (!tab) throw new Error('请先选择需要使用的页面');
|
||||
if (bridge.state !== 'connected') await request('bridge.connect');
|
||||
};
|
||||
|
||||
return <div className="section-view gateway-view">
|
||||
<RecordingWorkspace
|
||||
tab={tab}
|
||||
busy={busy}
|
||||
run={run}
|
||||
gatewayShared={transformShared}
|
||||
gatewayShared={bridge.state === 'connected'}
|
||||
onShareGateway={shareTransform}
|
||||
initialMode="gateway"
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -445,6 +445,14 @@ export function AuthorizationTestingWorkspace({
|
||||
}
|
||||
}, 'A/B 身份已验证,双方请求捕获已开始');
|
||||
|
||||
const openCrossBrowserWorkspace = () => run(async () => {
|
||||
if (!activeTab) throw new Error('请先选择需要测试的页面');
|
||||
if (!bridge.capabilities?.includes('yakit.browser_authorization.open')) {
|
||||
throw new Error('当前 Yak 引擎不支持打开跨浏览器工作区');
|
||||
}
|
||||
await request('authorization.yakit.open', { tabId: activeTab.id, mode });
|
||||
}, '已在 Yakit 打开跨浏览器越权测试,并带入当前页面');
|
||||
|
||||
const refreshWorkspaceDocuments = async (): Promise<BrowserAuthorizationWorkspace> => {
|
||||
if (!workspace || !leftTab || !rightTab) throw new Error('请先建立 A/B 工作区');
|
||||
const nextState = await request('grant.refresh');
|
||||
@@ -722,6 +730,21 @@ export function AuthorizationTestingWorkspace({
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{!workspace && <section className="authorization-cross-browser">
|
||||
<span><UserRoundPlus size={18} /></span>
|
||||
<div>
|
||||
<strong>使用 YTray 的多个浏览器实例</strong>
|
||||
<small>把当前页面作为资源身份带入 Yakit,再选择在线的浏览器 A、B 或 C 作为独立对照账号。</small>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={busy || !activeTab || !bridge.capabilities?.includes('yakit.browser_authorization.open')}
|
||||
onClick={() => void openCrossBrowserWorkspace()}
|
||||
>
|
||||
<ExternalLink size={15} />在 Yakit 选择实例
|
||||
</Button>
|
||||
</section>}
|
||||
|
||||
{!workspace ? <section className="authorization-identity-stage">
|
||||
<div className="authorization-mode">
|
||||
<span>测试类型</span>
|
||||
|
||||
@@ -116,6 +116,44 @@
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.authorization-cross-browser {
|
||||
min-height: 64px;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--authorization-accent) 24%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--authorization-accent) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.authorization-cross-browser > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--authorization-accent) 12%, var(--surface));
|
||||
color: var(--authorization-accent);
|
||||
}
|
||||
|
||||
.authorization-cross-browser strong,
|
||||
.authorization-cross-browser small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.authorization-cross-browser strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.authorization-cross-browser small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.authorization-identity-stage,
|
||||
.authorization-baseline-stage,
|
||||
.authorization-plan-stage {
|
||||
@@ -1458,6 +1496,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.authorization-cross-browser {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.authorization-cross-browser > .ui-button {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.authorization-heading,
|
||||
.authorization-prepare-bar,
|
||||
.authorization-section-heading {
|
||||
|
||||
@@ -30,6 +30,7 @@ interface RecordingWorkspaceProps {
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
initialMode?: 'gateway' | 'recording' | 'deep';
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<BrowserRecordingEvent['kind'], string> = {
|
||||
@@ -182,8 +183,9 @@ export function RecordingWorkspace({
|
||||
run,
|
||||
gatewayShared,
|
||||
onShareGateway,
|
||||
initialMode = 'recording',
|
||||
}: RecordingWorkspaceProps) {
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>(initialMode);
|
||||
const [autoArmRequest, setAutoArmRequest] = useState(0);
|
||||
const [autoRecoveryRequest, setAutoRecoveryRequest] = useState(0);
|
||||
const [recoveryProfileId, setRecoveryProfileId] = useState('');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useEffect, useMemo, useState, type ComponentType } from 'react';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
FileInput,
|
||||
KeyRound,
|
||||
@@ -20,6 +21,12 @@ import type {
|
||||
BrowserTransformValueSummary,
|
||||
} from '@/types/models';
|
||||
|
||||
interface FlowStageItem {
|
||||
id: string;
|
||||
stage: BrowserTransformExplanationStage;
|
||||
members: BrowserTransformExplanationStage[];
|
||||
}
|
||||
|
||||
const OWNER_LABELS: Record<BrowserTransformExplanationOwner, string> = {
|
||||
webfuzzer: 'Web Fuzzer',
|
||||
extension: '浏览器扩展',
|
||||
@@ -69,6 +76,44 @@ function operationLabel(operation: BrowserTransformExplanationStage['operations'
|
||||
return details.join(' · ');
|
||||
}
|
||||
|
||||
function displayStages(
|
||||
stages: BrowserTransformExplanationStage[],
|
||||
direction: BrowserTransformDirectionName,
|
||||
): FlowStageItem[] {
|
||||
const items: FlowStageItem[] = [];
|
||||
for (const stage of stages) {
|
||||
const assembly = stage.owner === 'extension' && (stage.kind === 'builtin' || stage.kind === 'output');
|
||||
const previous = items[items.length - 1];
|
||||
if (assembly && previous?.members.every((item) => (
|
||||
item.owner === 'extension' && (item.kind === 'builtin' || item.kind === 'output')
|
||||
))) {
|
||||
previous.members.push(stage);
|
||||
continue;
|
||||
}
|
||||
items.push({ id: stage.id, stage, members: [stage] });
|
||||
}
|
||||
return items.map((item) => {
|
||||
if (item.members.length === 1) return item;
|
||||
const first = item.members[0];
|
||||
const last = item.members[item.members.length - 1];
|
||||
return {
|
||||
...item,
|
||||
id: `${first.id}:assembly`,
|
||||
stage: {
|
||||
...first,
|
||||
id: `${first.id}:assembly`,
|
||||
title: direction === 'request' ? '浏览器扩展组装线上请求' : '浏览器扩展还原逻辑响应',
|
||||
summary: `${item.members.length} 个受限步骤,将中间结果写入最终报文`,
|
||||
nodeIds: item.members.flatMap((member) => member.nodeIds),
|
||||
inputPaths: first.inputPaths,
|
||||
outputPaths: last.outputPaths,
|
||||
operations: item.members.flatMap((member) => member.operations),
|
||||
evidence: item.members.flatMap((member) => member.evidence),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function TransformDataFlowView({
|
||||
profile,
|
||||
direction,
|
||||
@@ -85,6 +130,13 @@ export function TransformDataFlowView({
|
||||
const explained = profile.explanation?.directions.find((item) => item.direction === direction);
|
||||
const currentExecution = execution?.direction === direction ? execution : undefined;
|
||||
const availableDirections = profile.explanation?.directions.map((item) => item.direction) || [];
|
||||
const stages = useMemo(() => displayStages(explained?.stages || [], direction), [direction, explained?.stages]);
|
||||
const defaultOpenStageId = stages.find((item) => item.members.some((member) => member.kind === 'page-call'))?.id || '';
|
||||
const [openStageId, setOpenStageId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setOpenStageId(defaultOpenStageId);
|
||||
}, [defaultOpenStageId, direction, profile.id]);
|
||||
|
||||
if (!explained) return <div className="transform-flow-empty">
|
||||
<Code2 size={22} />
|
||||
@@ -97,7 +149,9 @@ export function TransformDataFlowView({
|
||||
<div>
|
||||
<span className="transform-data-flow__eyebrow">明文网关 · {direction === 'request' ? '请求方向' : '响应方向'}</span>
|
||||
<strong>{direction === 'request' ? '明文如何成为线上请求' : '线上响应如何还原为明文'}</strong>
|
||||
<p>{explained.summary}</p>
|
||||
<p>{stages.length === explained.stages.length
|
||||
? explained.summary
|
||||
: `${explained.stages.length} 个处理步骤已收拢为 ${stages.length} 个主要阶段`}</p>
|
||||
</div>
|
||||
{availableDirections.length > 1 && <div className="transform-flow-directions" role="tablist" aria-label="数据流方向">
|
||||
{availableDirections.map((item) => <button
|
||||
@@ -119,17 +173,24 @@ export function TransformDataFlowView({
|
||||
</div>
|
||||
|
||||
<div className="transform-flow-timeline">
|
||||
{explained.stages.map((stage, index) => {
|
||||
{stages.map((item, index) => {
|
||||
const { stage } = item;
|
||||
const Icon = STAGE_ICONS[stage.kind];
|
||||
const traces = currentExecution?.nodeTrace.filter((trace) => stage.nodeIds.includes(trace.nodeId)) || [];
|
||||
const stageDuration = traces.reduce((total, trace) => total + trace.durationMs, 0);
|
||||
const hasDetails = Boolean(stage.inputPaths.length || stage.outputPaths.length || stage.operations.length
|
||||
|| stage.evidence.length || stage.network || stage.source || traces.length);
|
||||
return <details className={`transform-flow-stage is-${stage.owner}`} key={stage.id} open={stage.kind === 'page-call'}>
|
||||
<summary>
|
||||
return <details className={`transform-flow-stage is-${stage.owner}`} key={item.id} open={openStageId === item.id}>
|
||||
<summary
|
||||
aria-expanded={openStageId === item.id}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (hasDetails) setOpenStageId((current) => current === item.id ? '' : item.id);
|
||||
}}
|
||||
>
|
||||
<span className="transform-flow-stage__rail">
|
||||
<i><Icon size={15} /></i>
|
||||
{index < explained.stages.length - 1 && <b />}
|
||||
{index < stages.length - 1 && <b />}
|
||||
</span>
|
||||
<span className="transform-flow-stage__main">
|
||||
<span className="transform-flow-stage__meta"><em>{OWNER_LABELS[stage.owner]}</em><i className={`is-${stage.proof}`}>{proofLabel(stage)}</i></span>
|
||||
@@ -137,16 +198,25 @@ export function TransformDataFlowView({
|
||||
<small>{stage.summary}</small>
|
||||
</span>
|
||||
<span className="transform-flow-stage__status">
|
||||
{traces.length ? <><CheckCircle2 size={14} /><time>{stageDuration.toFixed(1)} ms</time></> : hasDetails ? <span>详情</span> : null}
|
||||
{traces.length ? <><CheckCircle2 size={14} /><time>{stageDuration.toFixed(1)} ms</time></> : null}
|
||||
{hasDetails && <ChevronDown className="transform-flow-stage__chevron" size={14} />}
|
||||
</span>
|
||||
</summary>
|
||||
{hasDetails && <div className="transform-flow-stage__details">
|
||||
{item.members.length > 1 && <div className="transform-flow-steps">
|
||||
<span>阶段内操作</span>
|
||||
<ol>{item.members.map((member, memberIndex) => <li key={member.id}>
|
||||
<i>{memberIndex + 1}</i>
|
||||
<span><strong>{member.title}</strong><small>{member.operations.map(operationLabel).join(' · ') || member.summary}</small></span>
|
||||
<code>{[member.inputPaths.join('、'), member.outputPaths.join('、')].filter(Boolean).join(' → ')}</code>
|
||||
</li>)}</ol>
|
||||
</div>}
|
||||
{stage.network && <dl className="transform-flow-network">
|
||||
<div><dt>网络边界</dt><dd><code>{stage.network.method}</code> {stage.network.route}</dd></div>
|
||||
{stage.network.statusCode && <div><dt>录制响应</dt><dd>{stage.network.statusCode}</dd></div>}
|
||||
</dl>}
|
||||
{stage.operations.length > 0 && <div className="transform-flow-facts"><span>处理</span><ul>{stage.operations.map((operation, operationIndex) => <li key={`${operation.operation}:${operationIndex}`}><strong>{operationLabel(operation)}</strong>{operation.destination && <code>→ {operation.destination}</code>}</li>)}</ul></div>}
|
||||
{(stage.inputPaths.length > 0 || stage.outputPaths.length > 0) && <div className="transform-flow-paths">
|
||||
{item.members.length === 1 && stage.operations.length > 0 && <div className="transform-flow-facts"><span>处理逻辑</span><ul>{stage.operations.map((operation, operationIndex) => <li key={`${operation.operation}:${operationIndex}`}><strong>{operationLabel(operation)}</strong>{operation.destination && <code>→ {operation.destination}</code>}</li>)}</ul></div>}
|
||||
{item.members.length === 1 && (stage.inputPaths.length > 0 || stage.outputPaths.length > 0) && <div className="transform-flow-paths">
|
||||
{stage.inputPaths.length > 0 && <div><span>输入</span><p>{stage.inputPaths.map((path) => <code key={path}>{path}</code>)}</p></div>}
|
||||
{stage.outputPaths.length > 0 && <div><span>输出</span><p>{stage.outputPaths.map((path) => <code key={path}>{path}</code>)}</p></div>}
|
||||
</div>}
|
||||
|
||||
@@ -255,24 +255,28 @@
|
||||
.transform-flow-stage.is-extension { --owner-color: var(--primary); }
|
||||
.transform-flow-stage.is-page { --owner-color: #2563eb; }
|
||||
.transform-flow-stage.is-yak { --owner-color: #6b7280; }
|
||||
.transform-flow-stage > summary { min-width: 0; min-height: 92px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 11px; list-style: none; cursor: pointer; }
|
||||
.transform-flow-stage > summary { min-width: 0; min-height: 78px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 11px; border-radius: var(--radius-md); list-style: none; cursor: pointer; transition: background-color .14s ease, box-shadow .14s ease; }
|
||||
.transform-flow-stage > summary::-webkit-details-marker { display: none; }
|
||||
.transform-flow-stage__rail { min-height: 92px; display: grid; grid-template-rows: 30px minmax(0, 1fr); justify-items: center; padding-top: 16px; }
|
||||
.transform-flow-stage > summary:hover { background: color-mix(in srgb, var(--owner-color) 4%, transparent); }
|
||||
.transform-flow-stage[open] > summary { background: color-mix(in srgb, var(--owner-color) 7%, var(--surface)); box-shadow: inset 3px 0 0 var(--owner-color); }
|
||||
.transform-flow-stage__rail { min-height: 78px; display: grid; grid-template-rows: 30px minmax(0, 1fr); justify-items: center; padding-top: 13px; }
|
||||
.transform-flow-stage__rail > i { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--owner-color) 30%, var(--border)); border-radius: 50%; background: color-mix(in srgb, var(--owner-color) 8%, var(--surface)); color: var(--owner-color); font-style: normal; }
|
||||
.transform-flow-stage__rail > b { width: 1px; min-height: 38px; background: color-mix(in srgb, var(--owner-color) 28%, var(--border)); }
|
||||
.transform-flow-stage__main { min-width: 0; padding: 15px 0 13px; border-bottom: 1px solid var(--border); }
|
||||
.transform-flow-stage__main { min-width: 0; padding: 12px 0 11px; border-bottom: 1px solid var(--border); }
|
||||
.transform-flow-stage__meta { display: flex; align-items: center; gap: 7px; }
|
||||
.transform-flow-stage__meta > em { color: var(--owner-color); font-size: 10px; font-style: normal; font-weight: 700; }
|
||||
.transform-flow-stage__meta > i { padding: 1px 5px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted); font-size: 9px; font-style: normal; }
|
||||
.transform-flow-stage__meta > em { color: var(--owner-color); font-size: var(--text-xs); font-style: normal; font-weight: 700; }
|
||||
.transform-flow-stage__meta > i { padding: 1px 5px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||
.transform-flow-stage__meta > i.is-observed { background: var(--success-soft); color: var(--success); }
|
||||
.transform-flow-stage__meta > i.is-supported { background: var(--warning-soft); color: var(--warning); }
|
||||
.transform-flow-stage__main > strong { display: block; margin-top: 5px; font-size: var(--text-sm); }
|
||||
.transform-flow-stage__main > small { display: block; margin-top: 3px; color: var(--muted); font-size: var(--text-xs); line-height: 1.45; }
|
||||
.transform-flow-stage__status { min-width: 54px; padding: 15px 0 0 8px; display: flex; align-items: flex-start; justify-content: flex-end; gap: 4px; color: var(--muted); font-size: 10px; }
|
||||
.transform-flow-stage__status { min-width: 70px; padding: 13px 10px 0 8px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-flow-stage__status > svg { color: var(--success); }
|
||||
.transform-flow-stage__status time { font-variant-numeric: tabular-nums; }
|
||||
.transform-flow-stage[open] .transform-flow-stage__status > span { color: var(--foreground); }
|
||||
.transform-flow-stage__details { margin: -6px 0 10px 45px; padding: 0 0 14px; display: grid; gap: 10px; border-bottom: 1px solid var(--border); }
|
||||
.transform-flow-stage__status > .transform-flow-stage__chevron { color: var(--muted); transition: transform .16s ease; }
|
||||
.transform-flow-stage[open] .transform-flow-stage__chevron { transform: rotate(180deg); }
|
||||
.transform-flow-stage__details { margin: 4px 10px 14px 45px; padding: 13px 14px; display: grid; gap: 11px; border: 1px solid color-mix(in srgb, var(--owner-color) 16%, var(--border)); border-radius: var(--radius-md); background: var(--surface); box-shadow: 0 4px 14px rgb(15 23 42 / 4%); }
|
||||
.transform-flow-network { margin: 0; padding: 8px 10px; display: grid; gap: 5px; background: var(--surface-subtle); }
|
||||
.transform-flow-network > div { min-width: 0; display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; font-size: var(--text-xs); }
|
||||
.transform-flow-network dt { color: var(--muted); }
|
||||
@@ -305,6 +309,18 @@
|
||||
.transform-flow-runtime li strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.transform-flow-runtime li code { color: var(--muted); }
|
||||
.transform-flow-runtime li time { color: var(--success); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.transform-flow-steps { min-width: 0; display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; }
|
||||
.transform-flow-steps > span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; }
|
||||
.transform-flow-steps ol { min-width: 0; margin: 0; padding: 0; display: grid; gap: 2px; list-style: none; }
|
||||
.transform-flow-steps li { min-width: 0; min-height: 42px; padding: 6px 8px; display: grid; grid-template-columns: 20px minmax(130px, .8fr) minmax(160px, 1fr); align-items: center; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.transform-flow-steps li:last-child { border-bottom: 0; }
|
||||
.transform-flow-steps li > i { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--owner-color) 9%, var(--surface)); color: var(--owner-color); font-size: var(--text-xs); font-style: normal; font-weight: 700; }
|
||||
.transform-flow-steps li > span { min-width: 0; }
|
||||
.transform-flow-steps li strong,
|
||||
.transform-flow-steps li small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.transform-flow-steps li strong { font-size: var(--text-xs); }
|
||||
.transform-flow-steps li small { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-flow-steps li > code { min-width: 0; overflow: hidden; color: var(--muted-strong); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.transform-flow-changes { margin: 14px 0 0 45px; border-top: 2px solid var(--foreground); }
|
||||
.transform-flow-changes > header { min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); }
|
||||
.transform-flow-changes > header strong,
|
||||
@@ -438,6 +454,9 @@
|
||||
.transform-data-flow { padding-inline: 12px; }
|
||||
.transform-data-flow__head { flex-direction: column; }
|
||||
.transform-flow-paths { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-flow-steps { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-flow-steps li { grid-template-columns: 20px minmax(0, 1fr); }
|
||||
.transform-flow-steps li > code { grid-column: 2; }
|
||||
.transform-flow-stage__details,
|
||||
.transform-flow-changes { margin-left: 34px; }
|
||||
.transform-flow-empty { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
|
||||
@@ -99,6 +99,10 @@ describe('extension request schemas', () => {
|
||||
action: 'authorization.yakit.open',
|
||||
payload: { workspaceId: 'authorization-workspace-1' },
|
||||
}).action).toBe('authorization.yakit.open');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'authorization.yakit.open',
|
||||
payload: { tabId: 12, mode: 'horizontal' },
|
||||
}).payload).toEqual({ tabId: 12, mode: 'horizontal' });
|
||||
expect(parseExtensionRequest({
|
||||
action: 'network.capture.start',
|
||||
payload: {
|
||||
|
||||
@@ -302,7 +302,13 @@ const payloadSchemas = {
|
||||
payload: v.record(v.string(), v.unknown()),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(5_000), v.maxValue(120_000))),
|
||||
}),
|
||||
'authorization.yakit.open': v.strictObject({ workspaceId: id }),
|
||||
'authorization.yakit.open': v.union([
|
||||
v.strictObject({ workspaceId: id }),
|
||||
v.strictObject({
|
||||
tabId,
|
||||
mode: v.optional(v.picklist(['horizontal', 'vertical'])),
|
||||
}),
|
||||
]),
|
||||
'proxy.save': proxyProfile,
|
||||
'proxy.delete': v.strictObject({ id }),
|
||||
'proxy.switch': v.strictObject({ id }),
|
||||
|
||||
@@ -97,8 +97,8 @@ export interface ExtensionRequestMap {
|
||||
output: unknown;
|
||||
};
|
||||
'authorization.yakit.open': {
|
||||
input: { workspaceId: string };
|
||||
output: { workspaceId: string; opened: boolean };
|
||||
input: { workspaceId: string } | { tabId: number; mode?: 'horizontal' | 'vertical' };
|
||||
output: { workspaceId?: string; tabId?: number; opened: boolean };
|
||||
};
|
||||
'proxy.save': { input: ProxyProfile; output: ExtensionState };
|
||||
'proxy.delete': { input: { id: string }; output: ExtensionState };
|
||||
|
||||
Reference in New Issue
Block a user