perf: 优化工作流的节点UI和交互

This commit is contained in:
2026-03-01 15:52:22 +08:00
parent 4ef17da6f4
commit 05990072e6
10 changed files with 848 additions and 150 deletions

View File

@@ -1,30 +1,28 @@
<script lang="ts">
import {FloatingTrigger, Render} from './index';
import {FloatingTrigger} from './index';
import type {SelectItem} from '#types';
import {nodeIcons} from '../../consts';
let {
items,
onSelect,
value = [],
defaultValue = [],
expandAll = true,
multiple = false,
expandValue = [],
placeholder,
variant = 'default',
...rest
}: {
items: SelectItem[],
onSelect?: (item: SelectItem) => void,
value?: (any)[],
defaultValue?: (number | string | undefined)[],
expandAll?: boolean,
expandValue?: (number | string)[],
multiple?: boolean
placeholder?: string
variant?: 'default' | 'reference' | 'model'
[key: string]: any
} = $props();
let activeItemsState = $derived.by(() => {
const resultItems: SelectItem[] = [];
const fillResult = (items: SelectItem[]) => {
@@ -48,50 +46,179 @@
return resultItems;
});
let triggerObject: any;
function handlerOnSelect(item: SelectItem) {
let triggerObject: any = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
function closeMenu() {
triggerObject?.hide();
onSelect?.(item);
isOpen = false;
hoveredItem = null;
}
function handlerOnSelect(item: SelectItem) {
if (item.selectable !== false) {
onSelect?.(item);
closeMenu();
} else {
if (variant === 'reference') {
hoveredItem = item;
} else {
hoveredItem = hoveredItem === item ? null : item;
}
}
}
function handleMouseEnter(item: SelectItem) {
if (variant === 'reference' && item.children && item.children.length > 0) {
hoveredItem = item;
}
}
</script>
{#snippet selectItems(items: SelectItem[])}
{#each items as item, index (`${index}_${item.value}`)}
<button class="tf-select-content-item"
onclick={() => handlerOnSelect(item)}
>
<span>
{#if item.children && item.children.length > 0}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path
d="M12 14L8 10H16L12 14Z"></path></svg>
{/if}
</span>
<Render target={item.label} />
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
{#each items as item}
<button class="tf-select-default-item" style="padding-left: {10 + depth * 14}px" onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}>
<span class="tf-select-default-item-label">{item.label}</span>
</button>
{#if (item.children && item.children.length > 0 && (expandAll || expandValue.includes(item.value)))}
<div class="tf-select-content-children">
{@render selectItems(item.children)}
{#if item.children && item.children.length > 0}
<div class="tf-select-default-children">
{@render renderDefaultItems(item.children, depth + 1)}
</div>
{/if}
{/each}
{/snippet}
{#snippet renderModelItems(items: SelectItem[])}
{#each items as group}
{#if group.selectable === false}
<div class="tf-select-model-group-title">
{#if group.icon}
<span class="tf-select-model-group-icon">{@html group.icon}</span>
{/if}
<span>{group.label}</span>
</div>
{#each group.children || [] as model}
<button class="tf-select-model-item {value.includes(model.value) ? 'active' : ''}" onclick={(e) => { e.stopPropagation(); handlerOnSelect(model); }}>
<div class="tf-select-model-icon">
{#if model.icon}
{@html model.icon}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
</div>
<div class="tf-select-model-info">
<div class="tf-select-model-title">{model.label}</div>
{#if model.tags && model.tags.length > 0}
<div class="tf-select-model-tags">
{#each model.tags as tag}
<span class="tf-select-model-tag">{tag}</span>
{/each}
</div>
{/if}
{#if model.description}
<div class="tf-select-model-desc">{model.description}</div>
{/if}
</div>
</button>
{/each}
{:else}
<button class="tf-select-model-item {value.includes(group.value) ? 'active' : ''}" onclick={(e) => { e.stopPropagation(); handlerOnSelect(group); }}>
<div class="tf-select-model-icon">
{#if group.icon}
{@html group.icon}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
</div>
<div class="tf-select-model-info">
<div class="tf-select-model-title">{group.label}</div>
{#if group.tags && group.tags.length > 0}
<div class="tf-select-model-tags">
{#each group.tags as tag}
<span class="tf-select-model-tag">{tag}</span>
{/each}
</div>
{/if}
{#if group.description}
<div class="tf-select-model-desc">{group.description}</div>
{/if}
</div>
</button>
{/if}
{/each}
{/snippet}
{#snippet renderNestedItems(items: SelectItem[], depth = 0)}
{#each items as item}
<div class="tf-select-item-container" style="padding-left: {depth * 16}px">
<button class="tf-select-item {item.children && item.children.length > 0 ? 'has-children' : ''}"
onclick={() => handlerOnSelect(item)}>
<div class="tf-parameter-label">
<div class="tf-parameter-name-wrapper">
{#if item.children && item.children.length > 0}
<span class="tf-parameter-expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
{/if}
<span class="tf-parameter-name">{item.label}</span>
</div>
{#if item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
</button>
</div>
{#if item.children && item.children.length > 0}
<div class="tf-select-item-children">
{@render renderNestedItems(item.children, depth + 1)}
</div>
{/if}
{/each}
{/snippet}
<div {...rest} class="tf-select {rest['class']}">
<FloatingTrigger bind:this={triggerObject}>
<button class="tf-select-input nopan nodrag" {...rest}>
<FloatingTrigger bind:this={triggerObject} onShow={() => isOpen = true} onHide={() => { isOpen = false; hoveredItem = null; }}>
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
<div class="tf-select-input-value">
{#each activeItemsState as item, index (`${index}_${item.value}`)}
{#if !multiple}
{#if index === 0}
<Render target={item.label} />
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
{#if variant === 'reference' && item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
{/if}
{:else}
<Render target={item.label} />
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
{#if variant === 'reference' && item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
{#if index < activeItemsState.length - 1}
,
<span style="margin-right: 4px;">,</span>
{/if}
{/if}
{:else}
@@ -101,17 +228,486 @@
{/each}
</div>
<div class="tf-select-input-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z"></path>
</svg>
{#if variant === 'reference'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2" fill="currentColor"/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z"></path>
</svg>
{/if}
</div>
</button>
{#snippet floating()}
<div class="tf-select-content nopan nodrag nowheel ">
{@render selectItems(items)}
</div>
{#if variant === 'default'}
<div class="tf-select-default-wrapper nopan nodrag nowheel">
{@render renderDefaultItems(items)}
</div>
{:else if variant === 'model'}
<div class="tf-select-model-wrapper nopan nodrag nowheel">
<div class="tf-select-model-header">
<span class="tf-select-model-header-title">Model selection</span>
<svg class="tf-select-model-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<div class="tf-select-model-list">
{@render renderModelItems(items)}
</div>
</div>
{:else}
<div class="tf-select-wrapper nopan nodrag nowheel">
<!-- 一级列表:节点/分类 -->
<div class="tf-select-list tf-select-primary-list">
{#each items as item}
<button class="tf-select-item {hoveredItem?.value === item.value ? 'active' : ''}"
onmouseenter={() => handleMouseEnter(item)}
onclick={() => handlerOnSelect(item)}
>
<span class="tf-select-item-icon">
{#if item.icon}
{@html item.icon}
{:else if item.nodeType && nodeIcons[item.nodeType]}
{@html nodeIcons[item.nodeType]}
{:else if item.children && item.children.length > 0}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18M3 12h18M3 17h18"></path></svg>
{/if}
</span>
<span class="tf-select-item-label">{item.label}</span>
{#if item.children && item.children.length > 0}
<span class="tf-select-item-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</span>
{/if}
</button>
{/each}
</div>
<!-- 二级列表:树形展示参数及其嵌套子项 -->
{#if hoveredItem && hoveredItem.children && hoveredItem.children.length > 0}
<div class="tf-select-list tf-select-secondary-list">
{@render renderNestedItems(hoveredItem.children)}
</div>
{/if}
</div>
{/if}
{/snippet}
</FloatingTrigger>
</div>
<style lang="less">
.tf-select-default-wrapper {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
width: max-content;
min-width: 100%;
z-index: 99999;
box-sizing: border-box;
max-height: 220px;
overflow: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.tf-select-default-item {
display: flex;
align-items: center;
padding: 5px 10px;
border: none;
background: #fff;
border-radius: 5px;
cursor: pointer;
line-height: 100%;
gap: 2px;
width: 100%;
text-align: left;
font-size: 13px;
color: #333;
&:hover {
background: #f0f0f0;
}
}
.tf-select-default-children {
display: flex;
flex-direction: column;
}
/* Model Variant Styles */
.tf-select-model-wrapper {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 5px;
border: 1px solid #e5e7eb;
border-radius: 10px; /* slightly smaller radius */
width: 280px; /* Reduced width to be more compact */
z-index: 99999;
box-sizing: border-box;
max-height: 400px; /* slightly less tall */
overflow: hidden;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
.tf-select-model-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px; /* tighter padding */
border-bottom: 1px solid #f3f4f6;
background: #fff;
}
.tf-select-model-header-title {
font-size: 13px; /* smaller title */
font-weight: 600;
color: #111827;
}
.tf-select-model-header-icon {
width: 14px;
height: 14px;
color: #6b7280;
cursor: pointer;
}
.tf-select-model-list {
display: flex;
flex-direction: column;
padding: 8px; /* tighter padding */
overflow-y: auto;
gap: 4px; /* tighter gap between items */
}
.tf-select-model-group-title {
font-size: 12px; /* smaller group title */
font-weight: 500;
color: #6b7280;
margin-top: 6px;
margin-bottom: 2px;
padding-left: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tf-select-model-group-icon {
width: 14px; /* smaller icon */
height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tf-select-model-group-icon :global(svg), .tf-select-model-group-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.tf-select-model-group-title:first-child {
margin-top: 0;
}
.tf-select-model-item {
display: flex;
align-items: flex-start;
padding: 6px 8px; /* tighter item padding */
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
text-align: left;
gap: 10px;
transition: all 0.2s;
}
.tf-select-model-item:hover {
background: #f9fafb;
}
.tf-select-model-item.active {
background: #e0e7ff;
}
.tf-select-model-icon {
flex-shrink: 0;
width: 32px; /* smaller item icon */
height: 32px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
margin-top: 2px;
}
.tf-select-model-icon :global(svg), .tf-select-model-icon img {
width: 70%; /* icon inside box */
height: 70%;
object-fit: contain;
}
.tf-select-model-avatar {
font-size: 14px;
font-weight: 600;
color: #9ca3af;
}
.tf-select-model-info {
display: flex;
flex-direction: column;
gap: 2px; /* tighter info gap */
flex-grow: 1;
overflow: hidden;
}
.tf-select-model-title {
font-size: 13px; /* smaller font */
font-weight: 500;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tf-select-model-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tf-select-model-tag {
background: #f3f4f6;
color: #6b7280;
font-size: 10px; /* smaller tag */
padding: 1px 6px;
border-radius: 4px;
}
.tf-select-model-desc {
font-size: 11px; /* smaller desc */
color: #6b7280;
line-height: 1.3;
margin-top: 2px;
display: -webkit-box;
-webkit-line-clamp: 2; /* limit lines */
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Reference Variant Styles */
.tf-select-wrapper {
display: flex;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e7eb;
overflow: hidden;
min-width: 220px;
width: max-content;
max-width: 600px;
max-height: 480px;
transition: width 0.2s ease;
z-index: 99999;
}
.tf-select-list {
display: flex;
flex-direction: column;
padding: 8px;
overflow-y: auto;
&.tf-select-primary-list {
width: 220px;
flex-shrink: 0;
background: #f9fafb;
}
&.tf-select-secondary-list {
flex-grow: 1;
min-width: 200px;
background: #fff;
padding: 12px;
border-left: 1px solid #f3f4f6;
animation: slideIn 0.2s ease-out;
}
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.tf-select-item-container {
position: relative;
}
.tf-select-item-children {
position: relative;
&::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 1px;
background: #f0f0f0;
}
}
.tf-select-item {
display: flex;
align-items: center;
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
text-align: left;
font-size: 13px;
color: #374151;
gap: 10px;
width: 100%;
transition: all 0.15s;
margin-bottom: 2px;
&:hover {
background: #f3f4f6;
}
&.active {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
&.has-children {
background: #f8fafc;
margin-bottom: 4px;
&:hover {
background: #f1f5f9;
}
}
&-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #3474ff;
background: #cedafb;
border-radius: 5px;
padding: 3px;
box-sizing: border-box;
:global(svg) {
width: 16px;
height: 16px;
}
}
&-label {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-arrow {
width: 14px;
height: 14px;
color: #9ca3af;
}
}
.tf-parameter-label-input {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
.tf-select-item-icon-input {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #3474ff;
background: #cedafb;
border-radius: 4px;
padding: 2px;
box-sizing: border-box;
:global(svg) {
width: 12px;
height: 12px;
}
}
.tf-select-item-icon-input-model {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
:global(svg), img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
.tf-parameter-label {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
gap: 8px;
}
.tf-parameter-name-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.tf-parameter-expand-icon {
width: 12px;
height: 12px;
color: currentColor;
opacity: 0.6;
}
.tf-parameter-name {
color: inherit;
}
.tf-parameter-type {
background: rgba(0, 0, 0, 0.06);
color: #6b7280;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
</style>