feat(图标系统): 添加图标选择器和渲染器组件
- 新增 icons.ts 图标配置文件,定义图标分类和中文标签 - 新增 IconRenderer 组件,支持渲染 lucide 图标和 emoji - 新增 IconPicker 组件,提供分类浏览和搜索功能 - 支持向后兼容已有的 emoji 图标数据
This commit is contained in:
parent
66a58a2d3d
commit
bcb2141915
204
src/components/ui/IconPicker.tsx
Normal file
204
src/components/ui/IconPicker.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Search, ChevronDown } from 'lucide-react';
|
||||||
|
import { IconRenderer } from './IconRenderer';
|
||||||
|
import { ICON_CATEGORIES, ICON_LABELS, DEFAULT_ICON } from './icons';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface IconPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (icon: string) => void;
|
||||||
|
triggerSize?: number;
|
||||||
|
triggerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标选择器组件
|
||||||
|
*/
|
||||||
|
export function IconPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
triggerSize = 48,
|
||||||
|
triggerClassName,
|
||||||
|
}: IconPickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('recommended');
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
// 聚焦搜索框
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 搜索过滤
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return null; // 不搜索时返回 null,显示分类视图
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
// 遍历所有图标,检查名称和标签
|
||||||
|
Object.values(ICON_CATEGORIES).forEach((category) => {
|
||||||
|
category.icons.forEach((iconName) => {
|
||||||
|
// 检查图标名称
|
||||||
|
if (iconName.toLowerCase().includes(query)) {
|
||||||
|
if (!results.includes(iconName)) {
|
||||||
|
results.push(iconName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查标签
|
||||||
|
const labels = ICON_LABELS[iconName] || [];
|
||||||
|
if (labels.some((label) => label.toLowerCase().includes(query))) {
|
||||||
|
if (!results.includes(iconName)) {
|
||||||
|
results.push(iconName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// 选择图标
|
||||||
|
const handleSelect = (iconName: string) => {
|
||||||
|
onChange(iconName);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染图标按钮
|
||||||
|
const renderIconButton = (iconName: string, selected: boolean = false) => (
|
||||||
|
<button
|
||||||
|
key={iconName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(iconName)}
|
||||||
|
title={iconName}
|
||||||
|
className={cn(
|
||||||
|
'w-9 h-9 flex items-center justify-center rounded transition-colors',
|
||||||
|
selected
|
||||||
|
? 'bg-[var(--color-primary)] text-white'
|
||||||
|
: 'hover:bg-[var(--color-bg-hover)] text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconRenderer icon={iconName} size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={containerRef}>
|
||||||
|
{/* 触发按钮 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded hover:border-[var(--color-primary)] transition-colors',
|
||||||
|
triggerClassName
|
||||||
|
)}
|
||||||
|
style={{ width: triggerSize, height: triggerSize }}
|
||||||
|
>
|
||||||
|
<IconRenderer icon={value || DEFAULT_ICON} size={triggerSize * 0.5} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 下拉面板 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 w-[320px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded shadow-lg z-50 overflow-hidden">
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className="p-3 border-b border-[var(--color-border)]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="搜索图标..."
|
||||||
|
className="w-full pl-8 pr-3 py-2 text-sm bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{filteredIcons ? (
|
||||||
|
/* 搜索结果 */
|
||||||
|
<div className="p-3">
|
||||||
|
{filteredIcons.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-tertiary)] text-center py-4">
|
||||||
|
未找到匹配的图标
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] mb-2">
|
||||||
|
找到 {filteredIcons.length} 个图标
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{filteredIcons.map((iconName) =>
|
||||||
|
renderIconButton(iconName, iconName === value)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 分类视图 */
|
||||||
|
<div>
|
||||||
|
{/* 分类标签 */}
|
||||||
|
<div className="flex flex-wrap gap-1 p-2 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
|
||||||
|
{Object.entries(ICON_CATEGORIES).map(([key, category]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveCategory(key)}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded transition-colors',
|
||||||
|
activeCategory === key
|
||||||
|
? 'bg-[var(--color-primary)] text-white'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标网格 */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{ICON_CATEGORIES[activeCategory as keyof typeof ICON_CATEGORIES]?.icons.map(
|
||||||
|
(iconName) => renderIconButton(iconName, iconName === value)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/ui/IconRenderer.tsx
Normal file
72
src/components/ui/IconRenderer.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, ComponentType, SVGProps } from 'react';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import { isEmoji, DEFAULT_ICON } from './icons';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface IconRendererProps {
|
||||||
|
icon: string | null | undefined;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义图标组件类型
|
||||||
|
type IconComponentType = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||||
|
|
||||||
|
// 类型安全的图标映射
|
||||||
|
const icons = LucideIcons as unknown as Record<string, IconComponentType>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标渲染组件
|
||||||
|
* 支持渲染 lucide-react 图标和 emoji
|
||||||
|
*/
|
||||||
|
export function IconRenderer({
|
||||||
|
icon,
|
||||||
|
size = 24,
|
||||||
|
className,
|
||||||
|
fallback = DEFAULT_ICON,
|
||||||
|
}: IconRendererProps) {
|
||||||
|
const renderedIcon = useMemo(() => {
|
||||||
|
const iconValue = icon || fallback;
|
||||||
|
|
||||||
|
// 如果是 emoji,直接渲染文本
|
||||||
|
if (isEmoji(iconValue)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('flex items-center justify-center', className)}
|
||||||
|
style={{ fontSize: size * 0.9, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
{iconValue}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 lucide-react 获取图标组件
|
||||||
|
const IconComponent = icons[iconValue];
|
||||||
|
|
||||||
|
if (IconComponent) {
|
||||||
|
return <IconComponent size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找不到图标,使用默认图标
|
||||||
|
const FallbackIcon = icons[fallback];
|
||||||
|
if (FallbackIcon) {
|
||||||
|
return <FallbackIcon size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后的回退:显示问号图标
|
||||||
|
return <LucideIcons.HelpCircle size={size} className={className} />;
|
||||||
|
}, [icon, size, className, fallback]);
|
||||||
|
|
||||||
|
return <>{renderedIcon}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Lucide 图标组件
|
||||||
|
* 用于需要直接使用图标组件的场景
|
||||||
|
*/
|
||||||
|
export function getLucideIcon(iconName: string): IconComponentType | null {
|
||||||
|
return icons[iconName] || null;
|
||||||
|
}
|
||||||
142
src/components/ui/icons.ts
Normal file
142
src/components/ui/icons.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 图标配置文件
|
||||||
|
* 定义可用的 lucide-react 图标分类和推荐图标
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 图标分类配置
|
||||||
|
export const ICON_CATEGORIES = {
|
||||||
|
recommended: {
|
||||||
|
label: '推荐',
|
||||||
|
icons: [
|
||||||
|
'Code', 'Terminal', 'Bot', 'Brain', 'Lightbulb',
|
||||||
|
'Rocket', 'Sparkles', 'Palette', 'FileText', 'Globe',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
programming: {
|
||||||
|
label: '编程开发',
|
||||||
|
icons: [
|
||||||
|
'Code', 'Code2', 'Terminal', 'Database', 'Server',
|
||||||
|
'GitBranch', 'GitCommit', 'GitMerge', 'Bug', 'Cpu',
|
||||||
|
'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
|
||||||
|
'Cloud', 'CloudCog', 'Container', 'Boxes', 'Package',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: '设计创意',
|
||||||
|
icons: [
|
||||||
|
'Palette', 'Paintbrush', 'PaintBucket', 'Layers', 'Layout',
|
||||||
|
'LayoutGrid', 'Image', 'ImagePlus', 'Camera', 'Video',
|
||||||
|
'Film', 'Figma', 'Pen', 'PenTool', 'Brush',
|
||||||
|
'Eraser', 'Scissors', 'Crop', 'Frame', 'Shapes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
label: '写作文档',
|
||||||
|
icons: [
|
||||||
|
'FileText', 'File', 'FileCode', 'FileJson', 'FilePlus',
|
||||||
|
'BookOpen', 'Book', 'BookMarked', 'Notebook', 'NotebookPen',
|
||||||
|
'PenLine', 'Pencil', 'Type', 'Languages', 'Quote',
|
||||||
|
'AlignLeft', 'AlignCenter', 'List', 'ListOrdered', 'Text',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
label: '工具效率',
|
||||||
|
icons: [
|
||||||
|
'Wrench', 'Settings', 'Cog', 'SlidersHorizontal', 'Search',
|
||||||
|
'Filter', 'Calculator', 'Calendar', 'Clock', 'Timer',
|
||||||
|
'Zap', 'Bolt', 'Target', 'Crosshair', 'Compass',
|
||||||
|
'Ruler', 'Scale', 'Gauge', 'Activity', 'BarChart',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
communication: {
|
||||||
|
label: '沟通协作',
|
||||||
|
icons: [
|
||||||
|
'MessageSquare', 'MessageCircle', 'MessagesSquare', 'Mail', 'Send',
|
||||||
|
'Phone', 'PhoneCall', 'Video', 'Mic', 'Headphones',
|
||||||
|
'Users', 'UserPlus', 'UserCheck', 'Contact', 'AtSign',
|
||||||
|
'Share', 'Share2', 'Link', 'ExternalLink', 'Globe',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
label: 'AI 智能',
|
||||||
|
icons: [
|
||||||
|
'Bot', 'Brain', 'Sparkles', 'Wand', 'Wand2',
|
||||||
|
'Stars', 'Star', 'Atom', 'CircuitBoard', 'Scan',
|
||||||
|
'ScanLine', 'ScanSearch', 'Eye', 'Fingerprint', 'Shield',
|
||||||
|
'Lock', 'Key', 'BadgeCheck', 'Award', 'Trophy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
label: '通用',
|
||||||
|
icons: [
|
||||||
|
'Home', 'Heart', 'ThumbsUp', 'Smile', 'Sun',
|
||||||
|
'Moon', 'Lightbulb', 'Rocket', 'Plane', 'Car',
|
||||||
|
'Building', 'Store', 'ShoppingCart', 'Gift', 'Flag',
|
||||||
|
'Bookmark', 'Tag', 'Hash', 'Info', 'HelpCircle',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 所有可用图标的平铺列表(用于搜索)
|
||||||
|
export const ALL_ICONS = Object.values(ICON_CATEGORIES).flatMap(
|
||||||
|
(category) => category.icons
|
||||||
|
);
|
||||||
|
|
||||||
|
// 去重后的图标列表
|
||||||
|
export const UNIQUE_ICONS = [...new Set(ALL_ICONS)];
|
||||||
|
|
||||||
|
// 图标名称到中文的映射(用于搜索)
|
||||||
|
export const ICON_LABELS: Record<string, string[]> = {
|
||||||
|
Code: ['代码', '编程', 'code'],
|
||||||
|
Code2: ['代码', '编程', 'code'],
|
||||||
|
Terminal: ['终端', '命令行', 'terminal', 'console'],
|
||||||
|
Database: ['数据库', 'database', 'db'],
|
||||||
|
Server: ['服务器', 'server'],
|
||||||
|
GitBranch: ['分支', 'git', 'branch'],
|
||||||
|
Bug: ['调试', 'bug', '虫子'],
|
||||||
|
Cpu: ['处理器', 'cpu', '芯片'],
|
||||||
|
Bot: ['机器人', 'bot', 'ai', '助手'],
|
||||||
|
Brain: ['大脑', '智能', 'brain', 'ai'],
|
||||||
|
Sparkles: ['闪光', '魔法', 'sparkles', 'magic'],
|
||||||
|
Palette: ['调色板', '设计', 'palette', 'design'],
|
||||||
|
Paintbrush: ['画笔', '绘画', 'brush', 'paint'],
|
||||||
|
Layers: ['图层', 'layers'],
|
||||||
|
Image: ['图片', '图像', 'image', 'picture'],
|
||||||
|
FileText: ['文档', '文件', 'file', 'document'],
|
||||||
|
BookOpen: ['书籍', '阅读', 'book', 'read'],
|
||||||
|
PenLine: ['写作', '笔', 'pen', 'write'],
|
||||||
|
Languages: ['语言', '翻译', 'language', 'translate'],
|
||||||
|
Wrench: ['工具', '扳手', 'tool', 'wrench'],
|
||||||
|
Settings: ['设置', 'settings', 'config'],
|
||||||
|
Search: ['搜索', '查找', 'search', 'find'],
|
||||||
|
Calculator: ['计算器', 'calculator', '计算'],
|
||||||
|
Calendar: ['日历', 'calendar', '日期'],
|
||||||
|
Clock: ['时钟', '时间', 'clock', 'time'],
|
||||||
|
MessageSquare: ['消息', '聊天', 'message', 'chat'],
|
||||||
|
Mail: ['邮件', 'mail', 'email'],
|
||||||
|
Users: ['用户', '团队', 'users', 'team'],
|
||||||
|
Globe: ['全球', '网络', 'globe', 'web', 'internet'],
|
||||||
|
Home: ['首页', '主页', 'home'],
|
||||||
|
Heart: ['心', '喜欢', 'heart', 'like', 'love'],
|
||||||
|
Lightbulb: ['灯泡', '想法', 'lightbulb', 'idea'],
|
||||||
|
Rocket: ['火箭', '发射', 'rocket', 'launch'],
|
||||||
|
Star: ['星星', '收藏', 'star', 'favorite'],
|
||||||
|
Zap: ['闪电', '快速', 'zap', 'fast', 'lightning'],
|
||||||
|
Target: ['目标', 'target', 'goal'],
|
||||||
|
Shield: ['盾牌', '安全', 'shield', 'security'],
|
||||||
|
Lock: ['锁', '安全', 'lock', 'security'],
|
||||||
|
Key: ['钥匙', 'key'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认图标
|
||||||
|
export const DEFAULT_ICON = 'Bot';
|
||||||
|
|
||||||
|
// 判断是否为 emoji
|
||||||
|
export const isEmoji = (str: string | null | undefined): boolean => {
|
||||||
|
if (!str) return false;
|
||||||
|
// emoji 通常以高位 Unicode 开头
|
||||||
|
return /^[\u{1F300}-\u{1FAD6}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取图标类型
|
||||||
|
export type IconName = typeof UNIQUE_ICONS[number];
|
||||||
Loading…
Reference in New Issue
Block a user