perf: 优化并重构工作流幕布UI表现

This commit is contained in:
2026-03-05 21:40:05 +08:00
parent 03c5f2cd2e
commit 265bb79ba3
12 changed files with 1384 additions and 273 deletions

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import {getBezierPath, useConnection} from '@xyflow/svelte';
import FlowLinePath from './FlowLinePath.svelte';
import FlowMarkerDefs from './FlowMarkerDefs.svelte';
const connection = useConnection();
const markerId = 'tf-flow-connection-arrow-closed';
const path = $derived.by(() => {
const current = connection.current;
if (!current.inProgress) {
return '';
}
const [linePath] = getBezierPath({
sourceX: current.from.x,
sourceY: current.from.y,
sourcePosition: current.fromPosition,
targetX: current.to.x,
targetY: current.to.y,
targetPosition: current.toPosition
});
return linePath;
});
</script>
{#if connection.current.inProgress && path}
<FlowMarkerDefs id={markerId} />
<FlowLinePath
class="svelte-flow__connection-path"
{path}
markerEnd={`url(#${markerId})`}
animated={false}
/>
{/if}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import {EdgeLabel, type EdgeProps, getBezierPath} from '@xyflow/svelte';
import FlowLinePath from './FlowLinePath.svelte';
let {
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
markerStart,
markerEnd,
interactionWidth = 20,
label,
labelStyle
}: EdgeProps = $props();
const bezierPathResult = $derived.by(() =>
getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition
})
);
const path = $derived(bezierPathResult[0]);
const labelX = $derived(bezierPathResult[1]);
const labelY = $derived(bezierPathResult[2]);
</script>
<FlowLinePath path={path} {markerStart} {markerEnd} animated={true} />
{#if interactionWidth > 0}
<path
d={path}
stroke-opacity={0}
stroke-width={interactionWidth}
fill="none"
class="svelte-flow__edge-interaction"
></path>
{/if}
{#if label}
<EdgeLabel x={labelX} y={labelY} style={labelStyle} selectEdgeOnClick>
{label}
</EdgeLabel>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type {ClassValue} from 'svelte/elements';
let {
path = '',
markerStart,
markerEnd,
animated = true,
class: className = ''
}: {
path?: string;
markerStart?: string;
markerEnd?: string;
animated?: boolean;
class?: ClassValue;
} = $props();
</script>
<path
d={path}
fill="none"
marker-start={markerStart}
marker-end={markerEnd}
class={[
'svelte-flow__edge-path',
'tf-flow-line-path',
animated && 'tf-flow-line-path--animated',
className
]}
></path>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let {
id,
markerWidth = 20,
markerHeight = 20,
markerUnits = 'strokeWidth'
}: {
id: string;
markerWidth?: number;
markerHeight?: number;
markerUnits?: 'strokeWidth' | 'userSpaceOnUse';
} = $props();
</script>
<defs>
<marker
class="svelte-flow__arrowhead"
{id}
markerWidth={`${markerWidth}`}
markerHeight={`${markerHeight}`}
viewBox="-10 -10 20 20"
orient="auto-start-reverse"
{markerUnits}
refX="0"
refY="0"
>
<polyline
class="arrowclosed tf-flow-line-arrow-closed"
points="-5,-4 0,0 -5,4 -5,-4"
></polyline>
</marker>
</defs>
<style>
.tf-flow-line-arrow-closed {
stroke: var(--xy-edge-stroke);
fill: var(--xy-edge-stroke);
stroke-linecap: round;
stroke-linejoin: round;
}
</style>

View File

@@ -0,0 +1,345 @@
<script lang="ts">
import type {NodePaletteItem} from '../utils/nodePalette';
const { nodes = [], onSelect, placeholder = '搜索节点、插件、工作流' }: {
nodes: NodePaletteItem[];
onSelect?: (node: NodePaletteItem) => void;
placeholder?: string;
} = $props();
let keyword = $state('');
type HighlightPart = { text: string; hit: boolean };
const PINYIN_CHAR_MAP: Record<string, string> = {
: 'da', : 'mo', : 'xing',
: 'kai', : 'shi', : 'jie', : 'dian',
: 'xun', : 'huan',
: 'tiao', : 'jian', : 'pan', : 'duan',
: 'zhi', : 'shi', : 'ku',
: 'sou', : 'suo', : 'yin', : 'qing',
: 'qing', : 'qiu',
: 'dong', : 'tai', : 'dai', : 'ma',
: 'nei', : 'rong', : 'ban',
: 'yong', : 'hu', : 'que', : 'ren',
: 'jie', : 'shu',
: 'wen', : 'dang', : 'ti', : 'qu',
: 'sheng', : 'cheng',
: 'cha', : 'xun', : 'shu', : 'ju',
: 'cha', : 'zi',
: 'liu', : 'cheng',
: 'su', : 'cai', : 'tong', : 'bu',
: 'bao', : 'cun',
: 'ji', : 'chu',
: 'kuo', : 'zhan', : 'gong', : 'ju',
: 'yu', : 'shu', : 'ru',
: 'qi', : 'ta'
};
const categoryOrder = ['模型与流程', '逻辑', '输入输出', '数据与集成', '基础节点', '扩展工具', '其他'];
const normalizedKeyword = $derived(keyword.trim().toLowerCase());
const compactKeyword = $derived(normalizeForMatch(keyword));
function normalizeForMatch(text: string) {
return text.toLowerCase().replace(/\s+/g, '');
}
function toPinyinToken(text: string) {
let full = '';
let initials = '';
for (const char of text) {
const py = PINYIN_CHAR_MAP[char];
if (py) {
full += py;
initials += py[0];
continue;
}
const code = char.codePointAt(0) || 0;
const isAsciiDigit = code >= 48 && code <= 57;
const isAsciiUpper = code >= 65 && code <= 90;
const isAsciiLower = code >= 97 && code <= 122;
if (isAsciiDigit || isAsciiUpper || isAsciiLower) {
const lowerChar = char.toLowerCase();
full += lowerChar;
initials += lowerChar;
}
}
return { full, initials };
}
function isNodeMatched(item: NodePaletteItem) {
if (!compactKeyword) {
return true;
}
const directHaystack = normalizeForMatch(`${item.title} ${item.type} ${item.description || ''}`);
if (directHaystack.includes(compactKeyword)) {
return true;
}
const pinyinToken = toPinyinToken(item.title);
return pinyinToken.full.includes(compactKeyword) || pinyinToken.initials.includes(compactKeyword);
}
function getHighlightParts(title: string): HighlightPart[] {
if (!normalizedKeyword) {
return [{ text: title, hit: false }];
}
const source = title.toLowerCase();
const index = source.indexOf(normalizedKeyword);
if (index < 0) {
return [{ text: title, hit: false }];
}
const head = title.slice(0, index);
const middle = title.slice(index, index + normalizedKeyword.length);
const tail = title.slice(index + normalizedKeyword.length);
const parts: HighlightPart[] = [];
if (head) {
parts.push({ text: head, hit: false });
}
if (middle) {
parts.push({ text: middle, hit: true });
}
if (tail) {
parts.push({ text: tail, hit: false });
}
return parts;
}
const filteredNodes = $derived.by(() => {
if (!compactKeyword) {
return nodes;
}
return nodes.filter((item) => isNodeMatched(item));
});
const groupedNodes = $derived.by(() => {
const map = new Map<string, NodePaletteItem[]>();
for (const item of filteredNodes) {
const category = item.category || '其他';
if (!map.has(category)) {
map.set(category, []);
}
map.get(category)?.push(item);
}
return [...map.entries()].sort((a, b) => {
const ai = categoryOrder.indexOf(a[0]);
const bi = categoryOrder.indexOf(b[0]);
if (ai === -1 && bi === -1) {
return a[0].localeCompare(b[0]);
}
if (ai === -1) {
return 1;
}
if (bi === -1) {
return -1;
}
return ai - bi;
});
});
function onDragStart(event: DragEvent, node: NodePaletteItem) {
if (!event.dataTransfer) {
return;
}
const payload = {
type: node.type,
data: {
title: node.title,
description: node.description,
...(node.extra || {})
}
};
event.dataTransfer.setData('application/tinyflow', JSON.stringify(payload));
event.dataTransfer.effectAllowed = 'move';
}
function handleSelect(node: NodePaletteItem) {
onSelect?.(node);
}
</script>
<div class="tf-node-picker">
<div class="tf-node-picker-search">
<span class="tf-node-picker-search-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path>
</svg>
</span>
<input
type="text"
class="tf-node-picker-search-input"
bind:value={keyword}
placeholder={placeholder}
/>
</div>
<div class="tf-node-picker-content">
{#if groupedNodes.length === 0}
<div class="tf-node-picker-empty">暂无可用节点</div>
{:else}
{#each groupedNodes as [category, categoryNodes]}
<div class="tf-node-picker-group">
<div class="tf-node-picker-group-title">{category}</div>
<div class="tf-node-picker-grid">
{#each categoryNodes as node}
<button
type="button"
class="tf-node-picker-item nopan nodrag"
draggable={true}
ondragstart={(event) => onDragStart(event, node)}
onclick={() => handleSelect(node)}
>
<span class="tf-node-picker-item-icon">
{#if node.icon}
{@html node.icon}
{:else}
<span class="tf-node-picker-item-icon-fallback">N</span>
{/if}
</span>
<span class="tf-node-picker-item-title">
{#each getHighlightParts(node.title) as part}
<span class:tf-node-picker-item-title-hit={part.hit}>{part.text}</span>
{/each}
</span>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<style lang="less">
.tf-node-picker {
display: flex;
flex-direction: column;
gap: 10px;
}
.tf-node-picker-search {
display: flex;
align-items: center;
border: 1px solid var(--tf-border-color);
border-radius: 10px;
background: var(--tf-bg-surface);
padding: 0 10px;
height: 40px;
}
.tf-node-picker-search-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--tf-text-muted);
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
}
.tf-node-picker-search-input {
flex: 1;
margin-left: 8px;
border: none;
background: transparent;
outline: none;
color: var(--tf-text-primary);
font-size: 14px;
}
.tf-node-picker-content {
max-height: min(300px, 38vh);
overflow: auto;
padding-right: 2px;
}
.tf-node-picker-empty {
padding: 18px 4px;
font-size: 14px;
color: var(--tf-text-muted);
}
.tf-node-picker-group {
margin-bottom: 14px;
}
.tf-node-picker-group-title {
font-size: 13px;
color: var(--tf-text-secondary);
font-weight: 600;
margin-bottom: 8px;
}
.tf-node-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.tf-node-picker-item {
border: none;
border-radius: 10px;
background: transparent;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
max-width: none;
padding: 5px 8px;
text-align: left;
cursor: pointer;
color: var(--tf-text-primary);
}
.tf-node-picker-item:hover {
background: var(--tf-bg-hover);
}
.tf-node-picker-item:active {
transform: translateY(1px);
}
.tf-node-picker-item-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--tf-primary-color);
}
.tf-node-picker-item-icon :global(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
.tf-node-picker-item-icon-fallback {
width: 20px;
height: 20px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--tf-bg-hover);
color: var(--tf-primary-color);
font-size: 12px;
font-weight: 700;
}
.tf-node-picker-item-title {
font-size: 13px;
color: var(--tf-text-primary);
line-height: 1.2;
}
.tf-node-picker-item-title-hit {
color: var(--tf-primary-color);
font-weight: 700;
}
</style>