feat: api nodes icon

This commit is contained in:
Yourz
2026-02-08 15:17:56 +08:00
parent 70b514a48d
commit 98532a8217
8 changed files with 293 additions and 9 deletions

View File

@@ -12,6 +12,9 @@
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
@custom-variant touch (@media (hover: none));
@theme {

View File

@@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import BadgePill from './BadgePill.vue'
describe('BadgePill', () => {
it('renders text content', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Test Badge' }
})
expect(wrapper.text()).toBe('Test Badge')
})
it('renders icon when provided', () => {
const wrapper = mount(BadgePill, {
props: { icon: 'icon-[comfy--credits]', text: 'Credits' }
})
expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true)
})
it('applies iconClass to icon', () => {
const wrapper = mount(BadgePill, {
props: {
icon: 'icon-[comfy--credits]',
iconClass: 'text-amber-400'
}
})
const icon = wrapper.find('i')
expect(icon.classes()).toContain('text-amber-400')
})
it('uses default border color when no borderStyle', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Default' }
})
expect(wrapper.attributes('style')).toContain('border-color: #525252')
})
it('applies solid border color when borderStyle is a color', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Colored', borderStyle: '#f59e0b' }
})
expect(wrapper.attributes('style')).toContain('border-color: #f59e0b')
})
it('applies gradient border when borderStyle contains linear-gradient', () => {
const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)'
const wrapper = mount(BadgePill, {
props: { text: 'Gradient', borderStyle: gradient }
})
const style = wrapper.attributes('style')
expect(style).toContain('border-color: transparent')
expect(style).toContain('background-image')
})
it('applies filled style with background and text color', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
const style = wrapper.attributes('style')
expect(style).toContain('border-color: #f59e0b')
expect(style).toContain('background-color: #f59e0b33')
expect(style).toContain('color: #f59e0b')
})
it('has white text when not filled', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Not Filled', borderStyle: '#f59e0b' }
})
expect(wrapper.classes()).toContain('text-white')
})
it('does not have white text class when filled', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
expect(wrapper.classes()).not.toContain('text-white')
})
it('renders slot content', () => {
const wrapper = mount(BadgePill, {
slots: { default: 'Slot Content' }
})
expect(wrapper.text()).toBe('Slot Content')
})
})

View File

@@ -1,6 +1,8 @@
<template>
<span
class="flex items-center gap-1 rounded border border-neutral-600 px-1.5 py-0.5 text-xxs text-white"
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
<slot>{{ text }}</slot>
@@ -8,11 +10,45 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
const { borderStyle, filled } = defineProps<{
text?: string
icon?: string
iconClass?: string
borderStyle?: string
filled?: boolean
}>()
const textColorClass = computed(() =>
borderStyle && filled ? '' : 'text-white'
)
const customStyle = computed(() => {
if (!borderStyle) {
return { borderColor: '#525252' }
}
const isGradient = borderStyle.includes('linear-gradient')
if (isGradient) {
return {
borderColor: 'transparent',
backgroundImage: `linear-gradient(#1a1a1a, #1a1a1a), ${borderStyle}`,
backgroundOrigin: 'border-box',
backgroundClip: 'padding-box, border-box'
}
}
if (filled) {
return {
borderColor: borderStyle,
backgroundColor: `${borderStyle}33`,
color: borderStyle
}
}
return { borderColor: borderStyle }
})
</script>

View File

@@ -15,17 +15,19 @@
</h3>
<!-- Badges -->
<div class="flex flex-wrap gap-1">
<div class="flex flex-wrap gap-2">
<BadgePill
v-show="nodeDef.api_node && creditsLabel"
:text="creditsLabel"
icon="icon-[comfy--credits]"
icon-class="text-amber-400"
border-style="#f59e0b"
filled
/>
<BadgePill
v-show="nodeDef.api_node && categoryLabel"
:text="categoryLabel"
icon="icon-[lucide--bar-chart-2]"
:icon="getProviderIcon(categoryLabel ?? '')"
:border-style="getProviderBorderStyle(categoryLabel ?? '')"
/>
</div>
@@ -84,6 +86,7 @@ import { computed, ref } from 'vue'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import BadgePill from '@/components/common/BadgePill.vue'
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -76,6 +76,7 @@ import {
DEFAULT_TAB_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { TabId } from '@/types/nodeOrganizationTypes'
@@ -140,8 +141,7 @@ function getFolderIcon(node: TreeNode): string {
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
) {
const iconKey = node.label?.toLowerCase().replaceAll(/\s+/g, '-') ?? ''
return `icon-[comfy--${iconKey}]`
return getProviderIcon(node.label ?? '')
}
return 'icon-[ph--folder-fill]'
}

View File

@@ -305,8 +305,6 @@ interface Props {
loadingTier?: CheckoutTierKey | null
}
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
loadingTier: null

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
generateCategoryId,
getCategoryIcon,
getProviderBorderStyle,
getProviderIcon
} from './categoryUtil'
describe('getCategoryIcon', () => {
it('returns mapped icon for known category', () => {
expect(getCategoryIcon('all')).toBe('icon-[lucide--list]')
expect(getCategoryIcon('image')).toBe('icon-[lucide--image]')
expect(getCategoryIcon('video')).toBe('icon-[lucide--film]')
})
it('returns folder icon for unknown category', () => {
expect(getCategoryIcon('unknown-category')).toBe('icon-[lucide--folder]')
})
it('is case insensitive', () => {
expect(getCategoryIcon('ALL')).toBe('icon-[lucide--list]')
expect(getCategoryIcon('Image')).toBe('icon-[lucide--image]')
})
})
describe('getProviderIcon', () => {
it('returns icon class for simple provider name', () => {
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
})
it('converts spaces to hyphens', () => {
expect(getProviderIcon('Stability AI')).toBe('icon-[comfy--stability-ai]')
expect(getProviderIcon('Moonvalley Marey')).toBe(
'icon-[comfy--moonvalley-marey]'
)
})
it('handles multiple spaces', () => {
expect(getProviderIcon('Some Provider Name')).toBe(
'icon-[comfy--some-provider-name]'
)
})
it('converts to lowercase', () => {
expect(getProviderIcon('GEMINI')).toBe('icon-[comfy--gemini]')
})
})
describe('getProviderBorderStyle', () => {
it('returns solid color for single-color providers', () => {
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
})
it('returns gradient for dual-color providers', () => {
expect(getProviderBorderStyle('Gemini')).toBe(
'linear-gradient(90deg, #3186FF, #FABC12)'
)
expect(getProviderBorderStyle('Stability AI')).toBe(
'linear-gradient(90deg, #9D39FF, #E80000)'
)
})
it('returns fallback color for unknown providers', () => {
expect(getProviderBorderStyle('Unknown Provider')).toBe('#525252')
})
it('handles provider names with spaces', () => {
expect(getProviderBorderStyle('Stability AI')).toBe(
'linear-gradient(90deg, #9D39FF, #E80000)'
)
expect(getProviderBorderStyle('Moonvalley Marey')).toBe('#DAD9C5')
})
})
describe('generateCategoryId', () => {
it('generates category ID from group and title', () => {
expect(generateCategoryId('Generation', 'Image')).toBe('generation-image')
})
it('converts spaces to hyphens', () => {
expect(generateCategoryId('API Nodes', 'Open Source')).toBe(
'api-nodes-open-source'
)
})
it('converts to lowercase', () => {
expect(generateCategoryId('GENERATION', 'VIDEO')).toBe('generation-video')
})
})

View File

@@ -51,6 +51,71 @@ export const getCategoryIcon = (categoryId: string): string => {
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
}
/**
* Provider brand colors extracted from SVG icons.
* Each entry can be a single color or [color1, color2] for gradient.
*/
const PROVIDER_COLORS: Record<string, string | [string, string]> = {
bfl: '#ffffff',
bria: '#B6B6B6',
bytedance: ['#00C8D2', '#325AB4'],
gemini: ['#3186FF', '#FABC12'],
grok: '#B6B6B6',
hitpaw: '#B6B6B6',
ideogram: '#B6B6B6',
kling: ['#0BF2F9', '#FFF959'],
ltxv: '#B6B6B6',
luma: ['#004EFF', '#00FFFF'],
magnific: ['#EA5A3D', '#F1A64A'],
meshy: ['#67B700', '#FA418C'],
minimax: ['#E2167E', '#FE603C'],
'moonvalley-marey': '#DAD9C5',
openai: '#B6B6B6',
pixverse: ['#B465E6', '#E8632A'],
recraft: '#B6B6B6',
rodin: '#F7F7F7',
runway: '#B6B6B6',
sora: ['#6BB6FE', '#ffffff'],
'stability-ai': ['#9D39FF', '#E80000'],
tencent: ['#004BE5', '#00B3FE'],
topaz: '#B6B6B6',
tripo: ['#F6D85A', '#B6B6B6'],
veo: ['#4285F4', '#EB4335'],
vidu: ['#047FFE', '#40EDD8'],
wan: ['#6156EC', '#F4F3FD'],
wavespeed: '#B6B6B6'
}
/**
* Returns the icon class for an API node provider (e.g., BFL, OpenAI, Stability AI)
* @param providerName - The provider name from the node category
* @returns The icon class string (e.g., 'icon-[comfy--bfl]')
*/
export function getProviderIcon(providerName: string): string {
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
return `icon-[comfy--${iconKey}]`
}
/**
* Returns the border color(s) for an API node provider badge.
* @param providerName - The provider name from the node category
* @returns CSS color string or gradient definition
*/
export function getProviderBorderStyle(providerName: string): string {
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
const colors = PROVIDER_COLORS[iconKey]
if (!colors) {
return '#525252' // neutral-600 fallback
}
if (Array.isArray(colors)) {
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]})`
}
return colors
}
/**
* Generates a unique category ID from a category group and title
*/