cherry-studio/packages/ui/ICON_IMPLEMENTATION_GUIDE.md
MyPrototypeWhat a3062d6e38 Add ICON_IMPLEMENTATION_GUIDE and new SVG icons
- Created ICON_IMPLEMENTATION_GUIDE.md to document icon usage and common issues.
- Added multiple new SVG icons to the `icons/` directory, enhancing the icon library.
- Updated tailwind.css for improved styling consistency across components.
2025-11-14 13:31:17 +08:00

35 KiB
Raw Blame History

Cherry Studio UI - 彩色 Logo 图标系统实施方案

借鉴 Lucide IconNode 架构,为 Cherry Studio UI 库构建专门支持彩色品牌 Logo 的轻量级图标系统

📋 目录


项目概述

背景

Cherry Studio 是一个 Electron + React 的 monorepo 项目,主包通过 Vite alias 直接引用 UI 包的源码。我们需要为 UI 库添加 40+ 个 彩色品牌 Logo 图标(如 Anthropic、OpenAI、DeepSeek、Cohere 等)。

图标特点

这些图标与传统线性图标(如 Lucide有本质区别

特征 传统线性图标 我们的彩色 Logo
颜色方式 stroke="currentColor" fill="#3F3FAA" 等固定颜色
结构 简单 path 复杂嵌套 (<g>, <clipPath>, <defs>)
样式控制 可动态改变颜色/描边 必须保留原始颜色
使用场景 UI 通用图标 品牌标识

方案特点

  • 保留原色:完整保留 SVG 中的所有 fill 颜色
  • 支持嵌套:处理复杂的 <g><clipPath><defs> 结构
  • 借鉴 Lucide:采用 IconNode 数据结构,工厂模式创建组件
  • 轻量级实现:简化构建流程,无需复杂工具链
  • 源码直连:主包通过 alias 直接使用源码,支持热更新
  • TypeScript 支持:完整的类型推导和类型安全
  • Tailwind 友好:完美支持 Tailwind CSS 样式
  • 自动化生成:一键从 SVG 生成 React 组件

技术栈

  • React 19
  • TypeScript 5.8
  • SVGO 3.0 (SVG 优化)
  • Tailwind CSS 4.1

架构设计

目录结构

cherry-studio/
├── packages/ui/
│   ├── icons/                          # ① 源 SVG 文件目录
│   │   ├── arrow-right.svg
│   │   ├── check.svg
│   │   ├── close.svg
│   │   └── ... (40+ SVG 文件)
│   │
│   ├── scripts/
│   │   └── generate-icons.ts           # ② 生成脚本
│   │
│   ├── src/
│   │   └── components/
│   │       └── icons/
│   │           ├── Icon.tsx            # ③ 基础组件
│   │           ├── generated/          # ④ 自动生成的图标组件
│   │           │   ├── ArrowRight.tsx
│   │           │   ├── Check.tsx
│   │           │   ├── Close.tsx
│   │           │   └── index.ts
│   │           └── index.ts            # ⑤ 统一导出
│   │
│   └── package.json
│
└── src/renderer/src/                   # ⑦ 主包使用
    └── components/
        └── YourComponent.tsx

架构分层

┌─────────────────────────────────────────────────────────────┐
│                        用户层                                 │
│  <ArrowRight size={24} className="text-blue-500" />         │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                     具体图标组件层                            │
│  ArrowRight = createIcon('ArrowRight', iconNode)            │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                      工厂函数层                               │
│  createIcon(name, iconNode) → IconComponent                 │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                      基础组件层                               │
│  Icon: 渲染 SVG处理 props映射 IconNode                   │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                       数据层                                  │
│  IconNode: [['path', { d: '...' }], ['circle', {...}]]     │
└─────────────────────────────────────────────────────────────┘

核心概念

1. IconNode 数据结构(支持嵌套)

借鉴 Lucide 的核心设计,扩展为支持嵌套结构的数组格式:

type IconNode = [
  tag: string,                           // SVG 元素标签名
  attrs: Record<string, string | number>, // 元素属性
  children?: IconNode                     // 子元素(支持嵌套)
][];

// 简单示例(平面结构)
const simpleIcon: IconNode = [
  ['path', { d: 'M5 12h14', fill: '#000' }],
  ['circle', { cx: '12', cy: '12', r: '10', fill: '#fff' }]
];

// 复杂示例(嵌套结构,品牌 Logo 常见)
const complexIcon: IconNode = [
  ['g', { clipPath: 'url(#clip0)' }, [
    ['path', { d: 'M18 0H6...', fill: '#CA9F7B' }],
    ['path', { d: 'M15.38 6.43...', fill: '#191918' }]
  ]],
  ['defs', {}, [
    ['clipPath', { id: 'clip0' }, [
      ['rect', { width: '24', height: '24', fill: 'white' }]
    ]]
  ]]
];

优势:

  • 📦 体积极小(只存储数据,不存储模板)
  • 🔄 框架无关(可复用于 Vue/Svelte 等)
  • 渲染快速(直接 createElement无需解析
  • 🎨 保留原色(完整保留 fill 等样式属性)
  • 🌲 支持嵌套(处理复杂的品牌 Logo 结构)

2. 工厂模式

使用 createIcon 函数统一创建图标组件:

export function createIcon(
  componentName: string,
  iconNode: IconNode
) {
  const IconComponent = forwardRef<SVGSVGElement, IconProps>(
    (props, ref) => {
      return <Icon ref={ref} iconNode={iconNode} {...props} />;
    }
  );

  IconComponent.displayName = componentName;
  return IconComponent;
}

优势:

  • 代码复用40+ 图标共享同一套逻辑)
  • 统一行为(所有图标的 props 处理完全一致)
  • 易于维护(修改一处,全部更新)

3. 自动化生成

从 SVG 到 React 组件的自动化流程:

SVG 文件 → SVGO 优化 → 正则解析 → IconNode → 组件代码 → 写入文件

实施步骤

步骤 1创建基础 Icon 组件

创建文件:packages/ui/src/components/icons/Icon.tsx

import React, { forwardRef, memo } from 'react';
import { cn } from '@/utils';

/**
 * IconNode 数据结构(借鉴 lucide扩展支持嵌套
 * 格式: [标签名, 属性对象, 子元素(可选)]
 */
export type IconNode = [
  tag: string,
  attrs: Record<string, string | number>,
  children?: IconNode
][];

/**
 * Icon 组件的 Props专为彩色 Logo 优化)
 */
export interface IconProps extends React.SVGProps<SVGSVGElement> {
  /** 图标大小支持数字px或字符串如 "1rem" */
  size?: number | string;
  /** 自定义类名 */
  className?: string;
  /** 子元素 */
  children?: React.ReactNode;
}

/**
 * Icon 组件内部 Props包含 iconNode
 */
interface IconComponentProps extends IconProps {
  iconNode: IconNode;
  /** 图标名称(用于 className */
  iconName?: string;
}

/**
 * 递归渲染 IconNode支持嵌套结构
 */
function renderNodes(nodes: IconNode, keyPrefix = ''): React.ReactNode[] {
  return nodes.map((node, index) => {
    const [tag, attrs, children] = node;
    const key = `${keyPrefix}${index}`;

    // 如果有子元素,递归渲染
    const childElements = children ? renderNodes(children, `${key}-`) : undefined;

    return React.createElement(
      tag,
      { key, ...attrs },
      childElements
    );
  });
}

/**
 * 基础 Icon 组件(专为彩色品牌 Logo 设计)
 * - 保留 SVG 原始颜色(不强制 fill/stroke
 * - 支持嵌套结构g, clipPath, defs 等)
 * - 只控制 size 和 className
 */
export const Icon = memo(
  forwardRef<SVGSVGElement, IconComponentProps>(
    (
      {
        iconNode,
        iconName,
        size = 24,
        className,
        children,
        ...props
      },
      ref
    ) => {
      return (
        <svg
          ref={ref}
          xmlns="http://www.w3.org/2000/svg"
          width={size}
          height={size}
          viewBox="0 0 24 24"
          className={cn(
            'inline-block flex-shrink-0',
            iconName && `icon-${iconName}`,
            className
          )}
          {...props}
        >
          {/* 递归渲染 IconNode支持嵌套 */}
          {renderNodes(iconNode)}
          {children}
        </svg>
      );
    }
  )
);

Icon.displayName = 'Icon';

/**
 * 工厂函数:创建具体的图标组件
 * @param componentName - 组件名称PascalCase
 * @param iconNode - 图标数据
 * @returns 图标组件
 */
export function createIcon(
  componentName: string,
  iconNode: IconNode
) {
  const IconComponent = forwardRef<SVGSVGElement, IconProps>(
    (props, ref) => {
      return (
        <Icon
          ref={ref}
          iconNode={iconNode}
          iconName={componentName}
          {...props}
        />
      );
    }
  );

  IconComponent.displayName = componentName;
  return IconComponent;
}

步骤 2创建生成脚本

创建文件:packages/ui/scripts/generate-icons.ts

import fs from 'fs/promises';
import path from 'path';
import { optimize } from 'svgo';

const ICONS_DIR = path.join(__dirname, '../icons');
const OUTPUT_DIR = path.join(__dirname, '../src/components/icons/generated');

// SVGO 优化配置(专为彩色 Logo 优化)
const svgoConfig = {
  plugins: [
    {
      name: 'preset-default',
      params: {
        overrides: {
          // 保留 viewBox必须
          removeViewBox: false,
          // 不转换为 path保持原始形状
          convertShapeToPath: false,
          // 不移除隐藏元素(可能包含 defs
          removeHiddenElems: false,
        },
      },
    },
    {
      // 只移除 width 和 height保留所有颜色和样式
      name: 'removeAttrs',
      params: {
        attrs: '(width|height)',
      },
    },
  ],
};

/**
 * 转换命名kebab-case → PascalCase
 * 例: arrow-right → ArrowRight, 302ai → Ai302
 */
function toPascalCase(str: string): string {
  // 处理数字开头的情况(如 302ai
  if (/^\d/.test(str)) {
    // 提取开头的数字和后续部分
    const match = str.match(/^(\d+)(.*)$/);
    if (match) {
      const [, numbers, rest] = match;
      // 将数字放在后面302ai → Ai302
      str = rest + numbers;
    }
  }

  return str
    .split('-')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join('');
}

/**
 * 解析 SVG 为 IconNode 格式(支持嵌套结构)
 * 使用 svgson 库来可靠地处理复杂的嵌套结构
 */
async function parseSvg(svgContent: string): Promise<string> {
  const { parse } = await import('svgson');

  // 1. SVGO 优化
  const optimized = optimize(svgContent, svgoConfig);
  const svgString = optimized.data;

  // 2. 使用 svgson 解析为 AST
  const ast = await parse(svgString);

  // 3. 转换为 IconNode 格式
  const iconNode = convertToIconNode(ast.children);

  // 4. 格式化为 TypeScript 代码
  return formatIconNode(iconNode);
}

/**
 * 将 svgson AST 转换为 IconNode 格式
 */
function convertToIconNode(nodes: any[]): any[] {
  return nodes.map(node => {
    const { name, attributes, children } = node;

    if (children && children.length > 0) {
      const childNodes = convertToIconNode(children);
      return [name, attributes, childNodes];
    } else {
      return [name, attributes];
    }
  });
}

/**
 * 格式化 IconNode 为 TypeScript 代码
 */
function formatIconNode(nodes: any[], indent = 0): string {
  const indentStr = '  '.repeat(indent + 1);

  const items = nodes.map(node => {
    const [tag, attrs, children] = node;

    if (children) {
      const childrenStr = formatIconNode(children, indent + 1);
      return `${indentStr}['${tag}', ${JSON.stringify(attrs)}, ${childrenStr}]`;
    } else {
      return `${indentStr}['${tag}', ${JSON.stringify(attrs)}]`;
    }
  });

  if (indent === 0) {
    return `[\n${items.join(',\n')}\n]`;
  } else {
    return `[\n${items.join(',\n')}\n${'  '.repeat(indent)}]`;
  }
}

/**
 * 生成单个图标组件文件
 */
async function generateIconComponent(
  iconName: string,
  iconNode: string
): Promise<string> {
  const componentName = toPascalCase(iconName);

  return `import { forwardRef } from 'react';
import { createIcon, type IconProps } from '../Icon';
import type { IconNode } from '../Icon';

const iconNode: IconNode = ${iconNode};

/**
 * ${componentName} icon component
 *
 * @example
 * <${componentName} size={24} color="red" />
 * <${componentName} className="text-blue-500" />
 */
export const ${componentName} = createIcon('${componentName}', iconNode);

export default ${componentName};
`;
}

/**
 * 主函数:生成所有图标
 */
async function generateIcons() {
  console.log('🚀 开始生成图标组件...\n');

  try {
    // 检查 icons 目录是否存在
    try {
      await fs.access(ICONS_DIR);
    } catch {
      console.error(`❌ 错误: 找不到 icons 目录: ${ICONS_DIR}`);
      console.log(`💡 提示: 请创建 ${ICONS_DIR} 目录并放入 SVG 文件`);
      process.exit(1);
    }

    // 确保输出目录存在
    await fs.mkdir(OUTPUT_DIR, { recursive: true });

    // 读取所有 SVG 文件
    const files = await fs.readdir(ICONS_DIR);
    const svgFiles = files.filter(f => f.endsWith('.svg'));

    if (svgFiles.length === 0) {
      console.warn(`⚠️  警告: ${ICONS_DIR} 目录中没有找到 SVG 文件`);
      process.exit(0);
    }

    console.log(`📁 找到 ${svgFiles.length} 个 SVG 文件\n`);

    const exports: string[] = [];
    let successCount = 0;
    let errorCount = 0;

    // 处理每个 SVG 文件
    for (const file of svgFiles) {
      const iconName = file.replace('.svg', '');
      const componentName = toPascalCase(iconName);

      try {
        console.log(`⚙️  处理: ${iconName}`);

        // 读取 SVG 内容
        const svgPath = path.join(ICONS_DIR, file);
        const svgContent = await fs.readFile(svgPath, 'utf-8');

        // 解析为 IconNode
        const iconNode = await parseSvg(svgContent);

        // 生成组件代码
        const componentCode = await generateIconComponent(iconName, iconNode);

        // 写入文件
        const outputPath = path.join(OUTPUT_DIR, `${componentName}.tsx`);
        await fs.writeFile(outputPath, componentCode, 'utf-8');

        // 收集导出语句
        exports.push(`export { ${componentName} } from './${componentName}';`);
        successCount++;
      } catch (error) {
        console.error(`❌ 处理 ${iconName} 失败:`, error);
        errorCount++;
      }
    }

    // 生成 index.ts统一导出
    const indexContent = `/**
 * 自动生成的图标导出文件
 * 请勿手动编辑
 *
 * 生成时间: ${new Date().toISOString()}
 * 图标数量: ${successCount}
 */

${exports.sort().join('\n')}
`;

    await fs.writeFile(
      path.join(OUTPUT_DIR, 'index.ts'),
      indexContent,
      'utf-8'
    );

    // 输出结果
    console.log(`\n✅ 成功生成 ${successCount} 个图标组件!`);
    if (errorCount > 0) {
      console.log(`⚠️  失败 ${errorCount} 个图标`);
    }
    console.log(`📦 输出目录: ${OUTPUT_DIR}`);
  } catch (error) {
    console.error('\n❌ 生成过程发生错误:', error);
    process.exit(1);
  }
}

// 执行生成
generateIcons();

步骤 3更新 package.json

packages/ui/package.json 中添加:

{
  "scripts": {
    "generate:icons": "tsx scripts/generate-icons.ts",
    "build": "pnpm generate:icons && tsdown",
    "dev": "tsc -w"
  },
  "devDependencies": {
    "svgo": "^3.0.0",
    "svgson": "^5.3.1",
    "tsx": "^4.20.5"
  }
}

安装依赖:

cd packages/ui
pnpm add -D svgo svgson tsx

步骤 4创建统一导出

创建文件:packages/ui/src/components/icons/index.ts

/**
 * Icons 模块统一导出
 */

// 导出基础组件和类型
export { Icon, createIcon, type IconProps, type IconNode } from './Icon';

// 导出所有生成的图标
export * from './generated';

步骤 5更新主导出文件

packages/ui/src/components/index.ts 中添加:

// Icons
export * from './icons';

步骤 6配置子路径导出推荐

为了支持 @cherrystudio/ui/icons 导入路径,需要在 package.json 中配置 exports 字段:

{
  "name": "@cherrystudio/ui",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./src/components/index.ts",
      "types": "./src/components/index.ts"
    },
    "./icons": {
      "import": "./src/components/icons/index.ts",
      "types": "./src/components/icons/index.ts"
    }
  }
}

同时更新主包的 Vite 配置electron.vite.config.ts

export default defineConfig({
  resolve: {
    alias: {
      '@cherrystudio/ui': resolve('packages/ui/src/components'),
      '@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'),
    }
  }
});

这样你就可以使用两种导入方式:

// 推荐:从 icons 子路径导入84个图标
import { Anthropic, Deepseek } from '@cherrystudio/ui/icons';

// 兼容:从主包导入
import { Anthropic, Deepseek } from '@cherrystudio/ui';

步骤 7创建 icons 目录

cd packages/ui
mkdir icons

将你的 84 个 SVG 文件放入 icons/ 目录。


使用示例

// 推荐:从 icons 子路径导入84个图标语义更清晰
import { Anthropic, Deepseek, Cohere, Ai302 } from '@cherrystudio/ui/icons';

// 也可以:从主包导入(兼容方式)
// import { Anthropic, Deepseek } from '@cherrystudio/ui';

export function BasicExample() {
  return (
    <div className="flex gap-4 items-center">
      {/* 默认大小24px */}
      <Anthropic />

      {/* 自定义大小 */}
      <Deepseek size={32} />
      <Cohere size={48} />

      {/* 使用字符串大小 */}
      <Ai302 size="2rem" />
    </div>
  );
}

Tailwind CSS 集成

import { Anthropic, Deepseek } from '@cherrystudio/ui/icons';

export function TailwindExample() {
  return (
    <div>
      {/* 使用 Tailwind 控制大小 */}
      <Anthropic className="w-8 h-8" />

      {/* 悬停效果(注意:颜色不可更改,但可添加缩放、阴影等效果) */}
      <Deepseek className="w-10 h-10 hover:scale-110 cursor-pointer transition-transform" />

      {/* 响应式设计 */}
      <Anthropic className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10" />

      {/* 添加阴影和圆角效果 */}
      <Deepseek className="w-12 h-12 rounded-lg shadow-lg hover:shadow-xl transition-shadow" />
    </div>
  );
}

重要提示:

  • 不要尝试修改图标颜色(这些是品牌 Logo颜色是固定的
  • 可以修改 sizeclassName
  • 可以使用 Tailwind 的 scaleopacitytransformshadow 等效果

事件处理

import { Anthropic, Deepseek } from '@cherrystudio/ui/icons';

export function EventExample() {
  const handleClick = () => {
    console.log('Logo clicked!');
  };

  return (
    <div className="flex gap-4">
      {/* onClick 事件 */}
      <Anthropic onClick={handleClick} className="cursor-pointer hover:opacity-80" />

      {/* 其他事件 */}
      <Deepseek
        onMouseEnter={() => console.log('Mouse enter')}
        onMouseLeave={() => console.log('Mouse leave')}
        className="cursor-pointer transition-opacity hover:opacity-75"
      />
    </div>
  );
}

使用 ref

import { Anthropic } from '@cherrystudio/ui/icons';
import { useRef, useEffect } from 'react';

export function RefExample() {
  const iconRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    if (iconRef.current) {
      console.log('Logo SVG element:', iconRef.current);
      // 可以执行 DOM 操作,如添加动画
    }
  }, []);

  return <Anthropic ref={iconRef} size={32} />;
}

自定义彩色图标

如果你有自己的彩色 SVG 图标,可以手动创建:

import { createIcon, type IconNode } from '@cherrystudio/ui/icons';

// 定义自定义彩色图标数据(带嵌套和颜色)
const customLogoNode: IconNode = [
  ['g', { clipPath: 'url(#clip0)' }, [
    ['circle', { cx: '12', cy: '12', r: '10', fill: '#FF6B6B' }],
    ['path', { d: 'M12 6v6l4 2', stroke: '#fff', strokeWidth: '2' }]
  ]],
  ['defs', {}, [
    ['clipPath', { id: 'clip0' }, [
      ['rect', { width: '24', height: '24', fill: 'white' }]
    ]]
  ]]
];

// 创建自定义图标组件
const MyBrandLogo = createIcon('MyBrandLogo', customLogoNode);

export function CustomIconExample() {
  return <MyBrandLogo size={48} className="hover:scale-110 transition-transform" />;
}

组合使用

import { Anthropic, Deepseek, Cohere } from '@cherrystudio/ui/icons';

export function CompositeExample() {
  return (
    <div className="space-y-6">
      {/* AI 模型选择器 */}
      <div className="flex gap-4">
        {[
          { Logo: Anthropic, name: 'Claude' },
          { Logo: Deepseek, name: 'DeepSeek' },
          { Logo: Cohere, name: 'Cohere' }
        ].map(({ Logo, name }) => (
          <button
            key={name}
            className="flex flex-col items-center gap-2 p-4 border rounded-lg hover:bg-gray-50 transition-colors"
          >
            <Logo size={48} />
            <span className="text-sm font-medium">{name}</span>
          </button>
        ))}
      </div>

      {/* Logo 网格展示 */}
      <div className="grid grid-cols-4 gap-4">
        <div className="flex items-center justify-center p-4 bg-white rounded-lg shadow">
          <Anthropic size={32} />
        </div>
        <div className="flex items-center justify-center p-4 bg-white rounded-lg shadow">
          <Deepseek size={32} />
        </div>
        <div className="flex items-center justify-center p-4 bg-white rounded-lg shadow">
          <Cohere size={32} />
        </div>
      </div>
    </div>
  );
}

工作流程

开发流程

# 1. 准备 SVG 文件
# 将 SVG 文件放到 packages/ui/icons/ 目录

# 2. 生成图标组件
cd packages/ui
pnpm generate:icons

# 3. 在主包中使用
# 主包会通过 Vite alias 自动识别,直接导入使用

生成的文件

packages/ui/src/components/icons/
├── Icon.tsx                    # ✅ 手写(基础组件)
├── generated/                  # ⚠️  自动生成(不要手动编辑)
│   ├── ArrowRight.tsx
│   ├── Check.tsx
│   ├── Close.tsx
│   ├── ... (40+ 个文件)
│   └── index.ts
└── index.ts                    # ✅ 手写(统一导出)

构建流程

开发模式:

  • 主包通过 Vite alias 直接引用 UI 包源码
  • 支持热更新HMR
  • 无需构建 UI 包

生产构建:

# UI 包单独构建(如需发布)
cd packages/ui
pnpm build  # 会先 generate:icons然后 tsdown 打包

# 主包构建
cd cherry-studio
pnpm build  # Vite 会处理 UI 包的源码

常见问题

Q1: 为什么主包可以直接使用源码?

A: 因为主包的 Vite 配置了 alias

// electron.vite.config.ts
'@cherrystudio/ui': resolve('packages/ui/src')

这样导入的是 .tsx 源文件Vite 会像处理主包代码一样处理这些文件,支持热更新和 TypeScript 类型推导。


Q2: 彩色 Logo SVG 文件有什么要求?

A: 针对彩色品牌 Logo 的要求:

  • 使用标准的 24x24 viewBox推荐
  • 保留所有 fill 颜色(会自动保留)
  • 支持复杂嵌套结构(<g>, <clipPath>, <defs> 等)
  • 文件名使用 kebab-caseanthropic.svgdeep-seek.svg
  • 数字开头的文件名会自动转换(如 302ai.svgAi302 组件)

示例彩色 Logo SVG

<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
  <g clip-path="url(#clip0)">
    <path d="M18 0H6C2.68629 0..." fill="#CA9F7B"/>
    <path d="M15.3843 6.43481H12..." fill="#191918"/>
  </g>
  <defs>
    <clipPath id="clip0">
      <rect width="24" height="24" fill="white"/>
    </clipPath>
  </defs>
</svg>

注意事项:

  • ⚠️ 确保 SVG 格式正确(使用 Figma/Illustrator 导出时选择"优化"
  • ⚠️ ID 属性可能需要全局唯一(如 clip0 改为 clip-anthropic
  • ⚠️ 过大的 SVG 文件(超过 100KB建议先手动优化

Q3: 如何调试生成错误?

A: 如果某个图标生成失败:

  1. 检查 SVG 文件语法:使用浏览器直接打开 SVG 文件,看是否正常显示
  2. 测试 SVGO 优化
    npx svgo icons/your-icon.svg -o test.svg
    
  3. 查看生成脚本的错误日志:运行 pnpm generate:icons 时会显示详细错误
  4. 验证 SVG 结构:本方案支持所有标准 SVG 元素(通过 svgson 解析)
  5. 检查文件编码:确保 SVG 文件是 UTF-8 编码

Q4: 如何添加新图标?

A: 非常简单:

# 1. 将新的 SVG 文件放入 icons/ 目录
cp new-icon.svg packages/ui/icons/

# 2. 重新生成
cd packages/ui
pnpm generate:icons

# 3. 立即可用(无需重启开发服务器)
import { NewIcon } from '@cherrystudio/ui/icons';

Q5: 如何自定义生成的代码?

A: 修改 scripts/generate-icons.ts 中的 generateIconComponent 函数:

async function generateIconComponent(
  iconName: string,
  iconNode: string
): Promise<string> {
  const componentName = toPascalCase(iconName);

  // 在这里自定义生成的代码模板
  return `...`;
}

Q6: TypeScript 类型如何工作?

A: 完全自动,无需手动声明:

// IconProps 继承自 React.SVGProps<SVGSVGElement>
// 所以支持所有 SVG 属性

<Anthropic
  size={24}                // IconProps 自定义数字或字符串
  className="..."          // SVGProps
  onClick={() => {}}       // SVGProps
  onMouseEnter={() => {}}  // SVGProps
  style={{ opacity: 0.8 }} // SVGProps
  aria-label="Anthropic"   // SVGProps
  // ... 所有 SVG 属性
/>

注意: 彩色 Logo 版本移除了 colorstrokeWidth 属性(因为颜色是固定的)


Q7: 生成的文件需要提交到 Git 吗?

A: 推荐做法:

  • 提交 generated/ 目录(方便团队协作)
  • 在 CI/CD 中重新生成(确保一致性)

.gitignore 配置:

# 可选:不提交生成文件(需要在 CI 中生成)
# packages/ui/src/components/icons/generated/

# 必须提交 icons 源文件
!packages/ui/icons/*.svg

Q8: 能否修改 Logo 的颜色?

A: 不建议修改品牌 Logo 的颜色

这些是品牌官方 Logo颜色是品牌标识的一部分不应该修改

替代方案:

import { Anthropic } from '@cherrystudio/ui/icons';

// ✅ 可以调整透明度
<Anthropic className="opacity-50" />
<Anthropic style={{ opacity: 0.8 }} />

// ✅ 可以添加滤镜效果(慎用)
<Anthropic className="grayscale" />  // 灰度滤镜
<Anthropic style={{ filter: 'brightness(1.2)' }} />

// ❌ 不要尝试修改颜色
// <Anthropic style={{ fill: 'red' }} />  // 无效
// <Anthropic className="text-red-500" />  // 无效

为什么无法修改颜色?

  • 颜色信息存储在 IconNode 数据中(如 fill="#CA9F7B"
  • 这是设计决策:保护品牌标识的完整性
  • 如果需要可变颜色的图标,应该使用 Lucide 等线性图标库

Q9: 如何与 Lucide React 共存?

A: 完全可以同时使用:

// 使用彩色品牌 Logo84个
import { Anthropic, Deepseek } from '@cherrystudio/ui/icons';

// 使用 Lucide 线性图标
import { Heart, Settings, User } from 'lucide-react';

export function MixedExample() {
  return (
    <div>
      {/* 品牌 Logo固定颜色 */}
      <Anthropic size={24} />
      <Deepseek size={24} />

      {/* Lucide 图标:可变颜色 */}
      <Heart size={24} color="red" />
      <Settings size={24} className="text-blue-500" />
    </div>
  );
}

两者的 API 基本一致(都继承自 Lucide 的设计),但用途不同:

  • Cherry Studio Icons:彩色品牌 Logo
  • Lucide Icons:单色通用图标

Q10: 性能如何?

A: 性能优秀:

  • 📦 体积小:每个图标约 0.5-1.5KBIconNode 数据)
  • 渲染快:直接 createElement,无需解析
  • 🌲 Tree-shaking:只打包使用的图标
  • 💾 无运行时:零运行时依赖
  • 🎨 保留细节:完整保留彩色 Logo 的所有颜色和细节

对比:

传统方式(内联 SVG JSX~2-3KB/Logo
IconNode 方式(彩色):~0.5-1.5KB/Logo
节省:~50-75% 体积

注意: 彩色 Logo 比简单线性图标稍大,因为包含更多颜色和路径信息,但仍然非常高效。


Q11: 如何批量更新图标?

A: 直接替换 SVG 文件,然后重新生成:

# 1. 更新 SVG 文件(替换现有的或添加新的)
cp new-logos/*.svg packages/ui/icons/

# 2. 重新生成组件
cd packages/ui
pnpm generate:icons

# 3. 所有使用该图标的地方自动更新(无需修改代码)

批量处理技巧:

# 批量优化 SVG使用 SVGO
npx svggo -f icons/ -o icons-optimized/

# 批量重命名(确保 kebab-case
# 使用 rename 工具或脚本处理

附录

A. 命名规范

SVG 文件名(品牌 Logo

  • 使用 kebab-caseanthropic.svgdeep-seek.svg
  • 只包含小写字母、数字、连字符
  • 使用品牌官方名称
  • 数字开头会自动处理:302ai.svgAi302

组件名(自动生成):

  • 自动转换为 PascalCaseAnthropicDeepSeekAi302
  • 无需手动指定

B. SVGO 配置说明(彩色 Logo 专用)

{
  plugins: [
    {
      name: 'preset-default',
      params: {
        overrides: {
          removeViewBox: false,       // 保留 viewBox必须
          convertShapeToPath: false,  // 不转换为 path保持原始形状
          removeHiddenElems: false,   // 不移除隐藏元素(保留 defs
        },
      },
    },
    {
      name: 'removeAttrs',
      params: {
        attrs: '(width|height)',      // 只移除 width/height保留所有颜色
      },
    },
  ],
}

关键差异:

  • 保留 fill:不移除颜色属性(传统方案会移除)
  • 保留 defs:保留 clipPath、linearGradient 等定义
  • 保留嵌套:完整保留 <g> 嵌套结构
packages/ui/
├── icons/                           # 彩色 Logo SVG 源文件
│   ├── 302ai.svg
│   ├── aiOnly.svg
│   ├── aihubmix.svg
│   ├── anthropic.svg
│   ├── aws-bedrock.svg
│   ├── baichuan.svg
│   ├── baidu-cloud.svg
│   ├── bailian.svg
│   ├── bytedance.svg
│   ├── cephalon.svg
│   ├── cherryin.svg
│   ├── cohere.svg
│   ├── dashscope.svg
│   ├── deepseek.svg
│   └── ... (40+ 品牌 Logo)
│
├── scripts/
│   └── generate-icons.ts            # 生成脚本(支持嵌套和彩色)
│
├── src/
│   └── components/
│       └── icons/
│           ├── Icon.tsx             # 基础组件(支持嵌套渲染)
│           ├── index.ts             # 统一导出
│           └── generated/           # 自动生成的 Logo 组件
│               ├── Ai302.tsx
│               ├── AiOnly.tsx
│               ├── Aihubmix.tsx
│               ├── Anthropic.tsx
│               ├── AwsBedrock.tsx
│               ├── Baichuan.tsx
│               ├── BaiduCloud.tsx
│               ├── Bailian.tsx
│               ├── Bytedance.tsx
│               ├── Cephalon.tsx
│               ├── Cherryin.tsx
│               ├── Cohere.tsx
│               ├── Dashscope.tsx
│               ├── Deepseek.tsx
│               ├── ... (40+ 组件)
│               └── index.ts
│
└── package.json

总结

本方案借鉴 Lucide 的核心 IconNode 架构,专门为 Cherry Studio UI 库打造了一个轻量级、高性能的彩色品牌 Logo 图标系统

核心优势

保留原色:完整保留品牌 Logo 的所有颜色和细节 支持嵌套:处理复杂的 SVG 结构g, clipPath, defs 等) 自动生成:一键从 SVG 生成 React 组件,无需手动编写 高效轻量IconNode 数据结构,体积小、渲染快 类型安全:完整的 TypeScript 支持 Tailwind 友好:完美集成 Tailwind CSS 易于维护:工厂模式,统一管理,易于扩展

与传统方案的区别

特性 传统线性图标Lucide 本方案(彩色 Logo
颜色 单色,可动态改变 多色,保留原色
复杂度 简单路径 支持嵌套结构
用途 通用 UI 图标 品牌 Logo 展示
体积 ~0.3KB ~0.5-1.5KB

快速开始

cd packages/ui

# 1. 安装依赖
pnpm add -D svgo svgson tsx

# 2. 准备 SVG 文件(已有 84 个品牌 Logo
# icons/ 目录已包含: anthropic.svg, deepseek.svg, cohere.svg...

# 3. 生成组件
pnpm generate:icons

# 4. 在代码中使用(推荐使用 /icons 子路径)
# import { Anthropic, Deepseek } from '@cherrystudio/ui/icons';

现在就可以在主包中使用彩色品牌 Logo 了!🎉

下一步

  1. 阅读使用示例了解更多用法
  2. 查看常见问题解决疑惑
  3. 将更多品牌 Logo SVG 添加到 icons/ 目录
  4. 运行 pnpm generate:icons 生成新组件