feat(web): add @memoh/icon package and unify brand icon system

Replace FontAwesome/CDN brand icons with local SVG-based Vue components
in a new shared @memoh/icon package. Provider icon URLs in conf/providers
YAML files are replaced with preset names, intercepted by ProviderIcon
component on the frontend. SearchProviderLogo and ChannelIcon components
are migrated to @memoh/icon. All icon containers now use a unified
circular gray (rounded-full bg-muted) style. Adds wechat and matrix
channel icons.
This commit is contained in:
Acbox
2026-03-22 22:07:32 +08:00
parent 609ca49cf5
commit 897cc32194
210 changed files with 2076 additions and 159 deletions
+1
View File
@@ -14,6 +14,7 @@
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/vue-fontawesome": "^3.1.1",
"@memoh/icon": "workspace:*",
"@memoh/sdk": "workspace:*",
"@memoh/ui": "workspace:*",
"@pinia/colada": "^0.21.1",
@@ -0,0 +1,49 @@
<template>
<component
v-if="iconComponent"
:is="iconComponent"
:size="size"
v-bind="$attrs"
/>
<span v-else v-bind="$attrs">{{ fallback }}</span>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import {
Qq,
Telegram,
Discord,
Slack,
Feishu,
Wechat,
Matrix,
} from '@memoh/icon'
const channelIcons: Record<string, Component> = {
qq: Qq,
telegram: Telegram,
discord: Discord,
slack: Slack,
feishu: Feishu,
wechat: Wechat,
matrix: Matrix,
}
const props = withDefaults(defineProps<{
channel: string
size?: string | number
}>(), {
size: '1em',
})
defineOptions({ inheritAttrs: false })
const iconComponent = computed<Component | undefined>(() =>
channelIcons[props.channel],
)
const fallback = computed(() =>
props.channel.slice(0, 2).toUpperCase(),
)
</script>
@@ -1,31 +1,23 @@
<template>
<span
v-if="showBadge"
class="absolute -right-0.5 -bottom-0.5 flex size-4 items-center justify-center overflow-hidden rounded-full bg-muted border border-background text-muted-foreground"
class="absolute -right-0.5 -bottom-0.5 flex size-4 items-center justify-center rounded-full bg-muted ring-[1.5px] ring-background text-muted-foreground"
:title="channelLabel"
role="img"
:aria-label="channelLabel"
>
<img
v-if="channelImage"
:src="channelImage"
alt=""
class="size-full object-contain"
>
<FontAwesomeIcon
v-else
:icon="channelIcon!"
class="size-2.5"
<ChannelIcon
:channel="platformKey"
size="1em"
aria-hidden="true"
/>
</span>
</template>
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getChannelIcon, getChannelImage } from '@/utils/channel-icons'
import ChannelIcon from '@/components/channel-icon/index.vue'
const props = defineProps<{
platform: string
@@ -37,13 +29,11 @@ const isWebChannel = computed(() => {
const k = platformKey.value
return k === 'web' || k === ''
})
const channelImage = computed(() => getChannelImage(platformKey.value))
const channelIcon = computed(() => getChannelIcon(platformKey.value))
const channelLabel = computed(() => {
if (!platformKey.value) return ''
const key = `bots.channels.types.${platformKey.value}`
const out = t(key)
return out !== key ? out : platformKey.value.charAt(0).toUpperCase() + platformKey.value.slice(1)
})
const showBadge = computed(() => !isWebChannel.value && (channelImage.value || channelIcon.value))
const showBadge = computed(() => !isWebChannel.value)
</script>
@@ -0,0 +1,140 @@
import type { Component } from 'vue'
import {
Anthropic,
Azure,
AzureColor,
Baichuan,
BaichuanColor,
Bailian,
BailianColor,
Bedrock,
BedrockColor,
Claude,
ClaudeColor,
Cohere,
CohereColor,
Deepseek,
DeepseekColor,
Doubao,
DoubaoColor,
Fireworks,
FireworksColor,
Gemini,
GeminiColor,
Google,
GoogleBrandColor,
GoogleColor,
Groq,
Huggingface,
HuggingfaceColor,
Hunyuan,
HunyuanColor,
Kimi,
KimiColor,
Lmstudio,
Meta,
MetaColor,
Minimax,
MinimaxColor,
Mistral,
MistralColor,
Moonshot,
Newapi,
NewapiColor,
Nvidia,
NvidiaColor,
Ollama,
Openai,
Openrouter,
Qwen,
QwenColor,
Siliconcloud,
SiliconcloudColor,
Spark,
SparkColor,
Stepfun,
StepfunColor,
Together,
TogetherColor,
Vertexai,
VertexaiColor,
Volcengine,
VolcengineColor,
Xai,
Yi,
YiColor,
Zhipu,
ZhipuColor,
} from '@memoh/icon'
/**
* Maps preset icon names (stored in DB) to @memoh/icon Vue components.
* The key is the SVG filename without extension (e.g. 'openai', 'deepseek-color').
*/
export const iconMap: Record<string, Component> = {
'openai': Openai,
'anthropic': Anthropic,
'google': Google,
'google-color': GoogleColor,
'google-brand-color': GoogleBrandColor,
'deepseek': Deepseek,
'deepseek-color': DeepseekColor,
'groq': Groq,
'huggingface': Huggingface,
'huggingface-color': HuggingfaceColor,
'lmstudio': Lmstudio,
'minimax': Minimax,
'minimax-color': MinimaxColor,
'mistral': Mistral,
'mistral-color': MistralColor,
'moonshot': Moonshot,
'ollama': Ollama,
'openrouter': Openrouter,
'qwen': Qwen,
'qwen-color': QwenColor,
'xai': Xai,
'claude': Claude,
'claude-color': ClaudeColor,
'gemini': Gemini,
'gemini-color': GeminiColor,
'meta': Meta,
'meta-color': MetaColor,
'cohere': Cohere,
'cohere-color': CohereColor,
'azure': Azure,
'azure-color': AzureColor,
'nvidia': Nvidia,
'nvidia-color': NvidiaColor,
'fireworks': Fireworks,
'fireworks-color': FireworksColor,
'together': Together,
'together-color': TogetherColor,
'bedrock': Bedrock,
'bedrock-color': BedrockColor,
'vertexai': Vertexai,
'vertexai-color': VertexaiColor,
'baichuan': Baichuan,
'baichuan-color': BaichuanColor,
'zhipu': Zhipu,
'zhipu-color': ZhipuColor,
'yi': Yi,
'yi-color': YiColor,
'stepfun': Stepfun,
'stepfun-color': StepfunColor,
'kimi': Kimi,
'kimi-color': KimiColor,
'doubao': Doubao,
'doubao-color': DoubaoColor,
'spark': Spark,
'spark-color': SparkColor,
'hunyuan': Hunyuan,
'hunyuan-color': HunyuanColor,
'bailian': Bailian,
'bailian-color': BailianColor,
'siliconcloud': Siliconcloud,
'siliconcloud-color': SiliconcloudColor,
'volcengine': Volcengine,
'volcengine-color': VolcengineColor,
'newapi': Newapi,
'newapi-color': NewapiColor,
}
@@ -0,0 +1,39 @@
<template>
<component
v-if="iconComponent"
:is="iconComponent"
:size="size"
v-bind="$attrs"
/>
<img
v-else-if="isUrl"
:src="icon"
:width="size"
:height="size"
v-bind="$attrs"
/>
<slot v-else />
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { iconMap } from './icons.ts'
const props = withDefaults(defineProps<{
icon: string
size?: string | number
}>(), {
size: '1em',
})
defineOptions({ inheritAttrs: false })
const isUrl = computed(() =>
props.icon.startsWith('http://') || props.icon.startsWith('https://'),
)
const iconComponent = computed<Component | undefined>(() => {
if (isUrl.value) return undefined
return iconMap[props.icon]
})
</script>
@@ -1,33 +0,0 @@
/**
* Search provider icon registry (FontAwesome).
*
* To add a new provider icon:
* 1. Find the icon in FontAwesome (https://fontawesome.com/icons)
* or add a custom definition in custom-icons.ts
* 2. Import it in `main.ts` and add to `library.add()`
* 3. Add the [prefix, iconName] tuple to PROVIDER_ICONS below
*
* The key must match the `provider` field stored in the database (lowercase).
*/
const PROVIDER_ICONS: Record<string, [string, string]> = {
brave: ['fab', 'brave'],
bing: ['fab', 'microsoft'],
google: ['fab', 'google'],
yandex: ['fab', 'yandex'],
tavily: ['fac', 'tavily'],
jina: ['fac', 'jina'],
exa: ['fac', 'exa'],
bocha: ['fac', 'bocha'],
duckduckgo: ['fac', 'duckduckgo'],
searxng: ['fac', 'searxng'],
sogou: ['fac', 'sogou'],
serper: ['fac', 'serper'],
}
const DEFAULT_ICON: [string, string] = ['fas', 'globe']
export function getSearchProviderIcon(provider: string): [string, string] {
if (!provider) return DEFAULT_ICON
return PROVIDER_ICONS[provider.trim().toLowerCase()] ?? DEFAULT_ICON
}
@@ -1,33 +1,99 @@
<template>
<FontAwesomeIcon
:icon="icon"
class="shrink-0"
:class="sizeClass"
/>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
:class="containerClass"
>
<component
v-if="iconComponent"
:is="iconComponent"
:size="iconSize"
/>
<FontAwesomeIcon
v-else
:icon="['fas', 'globe']"
:class="iconSizeClass"
/>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getSearchProviderIcon } from './icons'
import { computed, type Component } from 'vue'
import {
Brave,
Bing,
BingColor,
Google,
GoogleColor,
Yandex,
Tavily,
TavilyColor,
Jina,
Exa,
ExaColor,
Bocha,
Duckduckgo,
Searxng,
Sogou,
Serper,
} from '@memoh/icon'
const searchIcons: Record<string, Component> = {
brave: Brave,
bing: BingColor,
'bing-mono': Bing,
google: GoogleColor,
'google-mono': Google,
yandex: Yandex,
tavily: TavilyColor,
'tavily-mono': Tavily,
jina: Jina,
exa: ExaColor,
'exa-mono': Exa,
bocha: Bocha,
duckduckgo: Duckduckgo,
searxng: Searxng,
sogou: Sogou,
serper: Serper,
}
const props = withDefaults(defineProps<{
/** The provider key, e.g. 'brave' */
provider: string
/** Size preset */
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
size: 'sm',
})
const sizeClass = computed(() => {
const iconComponent = computed<Component | undefined>(() =>
searchIcons[props.provider?.trim().toLowerCase()],
)
const containerClass = computed(() => {
switch (props.size) {
case 'xs': return 'size-3'
case 'xs': return 'size-5'
case 'sm': return 'size-7'
case 'md': return 'size-8'
case 'lg': return 'size-10'
default: return 'size-7'
}
})
const iconSize = computed(() => {
switch (props.size) {
case 'xs': return '0.625em'
case 'sm': return '1em'
case 'md': return '1.25em'
case 'lg': return '1.5em'
default: return '1em'
}
})
const iconSizeClass = computed(() => {
switch (props.size) {
case 'xs': return 'size-2.5'
case 'sm': return 'size-3.5'
case 'md': return 'size-4'
case 'lg': return 'size-5'
default: return 'size-3.5'
}
})
const icon = computed(() => getSearchProviderIcon(props.provider))
</script>
@@ -41,12 +41,9 @@
:class="{ 'bg-accent': selectedType === item.meta.type }"
@click="selectedType = item.meta.type as string"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
:class="channelBadgeClass(item.meta.type as string)"
>
{{ channelIcon(item.meta.type) }}
</div>
<span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type as string" size="1.25em" />
</span>
<div class="flex-1 text-left">
<div class="font-medium">
{{ item.meta.display_name }}
@@ -103,12 +100,9 @@
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
@click="addChannel(item.meta.type)"
>
<div
class="flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
:class="channelBadgeClass(item.meta.type)"
>
{{ channelIcon(item.meta.type) }}
</div>
<span class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type" size="1em" />
</span>
<span>{{ item.meta.display_name }}</span>
</button>
</PopoverContent>
@@ -148,6 +142,7 @@ import { useQuery } from '@pinia/colada'
import { getChannels, getBotsByIdChannelByPlatform } from '@memoh/sdk'
import type { HandlersChannelMeta, ChannelChannelConfig } from '@memoh/sdk'
import ChannelSettingsPanel from './channel-settings-panel.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
export interface BotChannelItem {
meta: HandlersChannelMeta
@@ -219,23 +214,4 @@ function addChannel(type: string) {
selectedType.value = type
}
function channelIcon(type: string): string {
const icons: Record<string, string> = {
qq: 'QQ',
telegram: 'TG',
matrix: 'MX',
feishu: '飞',
}
return icons[type] ?? type.slice(0, 2).toUpperCase()
}
function channelBadgeClass(type: string): string {
const classes: Record<string, string> = {
qq: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
telegram: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
matrix: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
feishu: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
}
return classes[type] ?? 'bg-secondary text-secondary-foreground'
}
</script>
@@ -2,13 +2,18 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">
{{ channelItem.meta.display_name }}
</h3>
<p class="text-sm text-muted-foreground">
{{ channelItem.meta.type }}
</p>
<div class="flex items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="channelItem.meta.type" size="1.5em" />
</span>
<div>
<h3 class="text-lg font-semibold">
{{ channelItem.meta.display_name }}
</h3>
<p class="text-sm text-muted-foreground">
{{ channelItem.meta.type }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<template v-if="isEditMode">
@@ -253,6 +258,7 @@ import { putBotsByIdChannelByPlatform, deleteBotsByIdChannelByPlatform, patchBot
import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema, ChannelUpsertConfigRequest } from '@memoh/sdk'
import { client } from '@memoh/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
interface BotChannelItem {
meta: HandlersChannelMeta
+8 -9
View File
@@ -16,13 +16,11 @@ import {
EmptyMedia,
EmptyTitle,
Button,
Avatar,
AvatarImage,
AvatarFallback,
} from '@memoh/ui'
import { getProviders } from '@memoh/sdk'
import type { ProvidersGetResponse } from '@memoh/sdk'
import AddProvider from '@/components/add-provider/index.vue'
import ProviderIcon from '@/components/provider-icon/index.vue'
import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue'
function getInitials(name: string | undefined) {
@@ -131,15 +129,16 @@ const openStatus = reactive({
}"
>
<span class="relative shrink-0">
<Avatar class="size-7">
<AvatarImage
<span class="flex size-7 items-center justify-center rounded-full bg-muted">
<ProviderIcon
v-if="providerItem.icon"
:src="providerItem.icon"
:icon="providerItem.icon"
size="1.25em"
/>
<AvatarFallback class="text-xs font-medium">
<span v-else class="text-xs font-medium text-muted-foreground">
{{ getInitials(providerItem.name) }}
</AvatarFallback>
</Avatar>
</span>
</span>
<span
v-if="providerItem.enable !== false"
class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-green-500 ring-2 ring-background"
+9 -7
View File
@@ -1,15 +1,16 @@
<template>
<div class="p-4">
<section class="flex items-center gap-3">
<Avatar class="size-10 shrink-0">
<AvatarImage
<span class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<ProviderIcon
v-if="curProvider?.icon"
:src="curProvider.icon"
:icon="curProvider.icon"
size="1.5em"
/>
<AvatarFallback class="text-sm font-medium">
<span v-else class="text-sm font-medium text-muted-foreground">
{{ getInitials(curProvider?.name) }}
</AvatarFallback>
</Avatar>
</span>
</span>
<h4 class="scroll-m-20 tracking-tight">
{{ curProvider?.name }}
</h4>
@@ -37,7 +38,8 @@
</template>
<script setup lang="ts">
import { Separator, Avatar, AvatarImage, AvatarFallback } from '@memoh/ui'
import { Separator } from '@memoh/ui'
import ProviderIcon from '@/components/provider-icon/index.vue'
function getInitials(name: string | undefined) {
const label = name?.trim() ?? ''
-28
View File
@@ -1,28 +0,0 @@
const LOCAL_CHANNEL_IMAGES: Record<string, string> = {
feishu: '/channels/feishu.png',
matrix: '/channels/matrix.svg',
telegram: '/channels/telegram.webp',
}
const CHANNEL_ICONS: Record<string, [string, string]> = {
qq: ['fab', 'qq'],
telegram: ['fab', 'telegram'],
matrix: ['fas', 'hashtag'],
feishu: ['fas', 'comment-dots'],
web: ['fas', 'globe'],
slack: ['fab', 'slack'],
discord: ['fab', 'discord'],
email: ['fas', 'envelope'],
}
const DEFAULT_ICON: [string, string] = ['far', 'comment']
export function getChannelIcon(platformKey: string): [string, string] {
if (!platformKey) return DEFAULT_ICON
return CHANNEL_ICONS[platformKey] ?? DEFAULT_ICON
}
export function getChannelImage(platformKey: string): string | null {
if (!platformKey) return null
return LOCAL_CHANNEL_IMAGES[platformKey] ?? null
}