diff --git a/frontend/src/components/DynamicIcon/README.md b/frontend/src/components/DynamicIcon/README.md new file mode 100644 index 00000000..d43fbdaf --- /dev/null +++ b/frontend/src/components/DynamicIcon/README.md @@ -0,0 +1,78 @@ +# DynamicIcon 组件 + +动态图标渲染组件,支持所有 Lucide React 图标和 Emoji。 + +## 功能特性 + +- ✅ 支持所有 Lucide React 图标(1000+ 个) +- ✅ 支持 Emoji 表情 +- ✅ 自动识别图标类型 +- ✅ 支持自定义默认图标 +- ✅ 完整的 TypeScript 类型支持 + +## 使用方法 + +### 基础用法 + +```tsx +import DynamicIcon from '@/components/DynamicIcon'; + +// 使用 Lucide 图标名称 + + + +// 使用 Emoji + + +``` + +### 自定义默认图标 + +```tsx +import DynamicIcon from '@/components/DynamicIcon'; +import { Star } from 'lucide-react'; + +// 当图标不存在时显示 Star + +``` + +### Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| name | string | - | 图标名称(Lucide 图标名或 Emoji) | +| fallback | LucideIcon | FolderKanban | 默认图标组件 | +| className | string | "h-4 w-4" | CSS 类名 | +| ...其他 | LucideProps | - | 其他 Lucide 图标属性 | + +## 示例 + +### 在表格中显示图标 + +```tsx + + {record.icon ? ( +
+ +
+ ) : ( + - + )} +
+``` + +### 在按钮中显示图标 + +```tsx + +``` + +## 注意事项 + +1. 图标名称必须与 Lucide React 的图标名称完全匹配(区分大小写) +2. 查看所有可用图标:https://lucide.dev/icons +3. Emoji 会自动以 `text-lg` 大小渲染 + diff --git a/frontend/src/components/DynamicIcon/index.tsx b/frontend/src/components/DynamicIcon/index.tsx new file mode 100644 index 00000000..3aae757e --- /dev/null +++ b/frontend/src/components/DynamicIcon/index.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { icons, LucideProps } from 'lucide-react'; +import { FolderKanban } from 'lucide-react'; + +interface DynamicIconProps extends Omit { + /** 图标名称(Lucide React 图标名)或 emoji */ + name?: string; + /** 默认图标(当找不到指定图标时显示) */ + fallback?: React.ComponentType; +} + +/** + * 动态图标组件 + * + * 支持: + * 1. Lucide React 的所有图标(通过图标名称字符串) + * 2. Emoji 表情 + * + * @example + * ```tsx + * // 使用 Lucide 图标名称 + * + * + * // 使用 emoji + * + * + * // 自定义默认图标 + * + * ``` + */ +const DynamicIcon: React.FC = ({ + name, + fallback: FallbackIcon = FolderKanban, + className = "h-4 w-4", + ...props +}) => { + if (!name) { + return ; + } + + // 检测是否为 emoji(包括各种 Unicode emoji 范围) + const isEmoji = name.length <= 4 && /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E0}-\u{1F1FF}]/u.test(name); + + if (isEmoji) { + return {name}; + } + + // 从 lucide-react 的 icons 对象中动态获取图标组件 + const IconComponent = icons[name as keyof typeof icons]; + + if (IconComponent) { + return ; + } + + // 如果找不到,显示默认图标 + return ; +}; + +export default DynamicIcon; + diff --git a/frontend/src/components/LucideIconSelect/README.md b/frontend/src/components/LucideIconSelect/README.md new file mode 100644 index 00000000..284b1493 --- /dev/null +++ b/frontend/src/components/LucideIconSelect/README.md @@ -0,0 +1,145 @@ +# LucideIconSelect 组件 + +Lucide React 图标选择器组件,提供可视化的图标选择界面。 + +## 功能特性 + +- ✅ 支持所有 Lucide React 图标(1000+ 个) +- ✅ 搜索功能 +- ✅ 分类浏览(12 个常用分类) +- ✅ 实时预览 +- ✅ 响应式设计 +- ✅ 完整的 TypeScript 类型支持 + +## 使用方法 + +### 基础用法 + +```tsx +import { useState } from 'react'; +import LucideIconSelect from '@/components/LucideIconSelect'; + +function MyComponent() { + const [iconName, setIconName] = useState(''); + const [open, setOpen] = useState(false); + + return ( + <> + + + + + ); +} +``` + +### 在表单中使用 + +```tsx +import { useForm } from 'react-hook-form'; +import LucideIconSelect from '@/components/LucideIconSelect'; +import DynamicIcon from '@/components/DynamicIcon'; + +function FormExample() { + const [iconSelectOpen, setIconSelectOpen] = useState(false); + const form = useForm(); + + return ( + ( + + 图标 + +
+ setIconSelectOpen(true)} + className="cursor-pointer" + /> + {field.value && ( +
+ +
+ )} +
+
+ +
+ )} + /> + ); +} +``` + +### Props + +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| value | string | 否 | 当前选中的图标名称 | +| onChange | (iconName: string) => void | 否 | 图标变化回调 | +| open | boolean | 是 | 是否显示弹窗 | +| onOpenChange | (open: boolean) => void | 是 | 关闭弹窗回调 | + +## 图标分类 + +组件内置了以下分类: + +- **常用** - 常用业务图标 +- **文件与文档** - 文件相关图标 +- **用户与团队** - 用户管理图标 +- **开发与代码** - 开发工具图标 +- **数据与存储** - 数据库、存储图标 +- **网络与服务** - 网络、服务器图标 +- **时间与日历** - 时间、日期图标 +- **操作与控制** - 操作按钮图标 +- **通知与提醒** - 通知相关图标 +- **编辑与格式** - 编辑工具图标 +- **导航与箭头** - 导航、箭头图标 +- **状态与标记** - 状态指示图标 + +## 自定义配置 + +如需添加或修改分类,可编辑 `src/config/lucideIcons.ts` 文件: + +```ts +export const ICON_CATEGORIES = { + '自定义分类': [ + 'Icon1', 'Icon2', 'Icon3' + ], + // ... 其他分类 +}; +``` + +## 示例截图 + +选择器提供: +- 🔍 搜索框 - 快速查找图标 +- 📑 分类标签 - 按类别浏览 +- 🎨 图标网格 - 可视化选择 +- ✅ 当前选中 - 实时预览 + +## 相关组件 + +配合 `DynamicIcon` 组件使用效果更佳: + +```tsx +import DynamicIcon from '@/components/DynamicIcon'; +import LucideIconSelect from '@/components/LucideIconSelect'; +``` + diff --git a/frontend/src/components/LucideIconSelect/index.tsx b/frontend/src/components/LucideIconSelect/index.tsx new file mode 100644 index 00000000..3e5cc9b4 --- /dev/null +++ b/frontend/src/components/LucideIconSelect/index.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Search } from "lucide-react"; +import { searchLucideIcons, ICON_CATEGORIES } from '@/config/lucideIcons'; +import DynamicIcon from '@/components/DynamicIcon'; + +interface LucideIconSelectProps { + /** 当前选中的图标名称 */ + value?: string; + /** 图标变化回调 */ + onChange?: (iconName: string) => void; + /** 是否显示弹窗 */ + open: boolean; + /** 关闭弹窗回调 */ + onOpenChange: (open: boolean) => void; +} + +/** + * Lucide 图标选择器组件 + * + * 支持: + * - 搜索图标 + * - 分类浏览 + * - 点击选择 + */ +const LucideIconSelect: React.FC = ({ + value, + onChange, + open, + onOpenChange +}) => { + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('all'); + + // 过滤图标列表 + const iconList = useMemo(() => { + return searchLucideIcons(search, category); + }, [search, category]); + + // 选择图标 + const handleSelect = (iconName: string) => { + onChange?.(iconName); + onOpenChange(false); + setSearch(''); + setCategory('all'); + }; + + return ( + + + + 选择图标 + + +
+ {/* 搜索框 */} +
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ + {/* 分类标签 */} + + + 全部 ({iconList.length}) + {Object.keys(ICON_CATEGORIES).map((cat) => ( + + {cat} + + ))} + + + + {/* 图标网格 */} +
+ {iconList.length > 0 ? ( + iconList.map(({ name, component: Icon }) => ( + + )) + ) : ( +
+ 未找到匹配的图标 +
+ )} +
+
+
+ + {/* 当前选中 */} + {value && ( +
+ 当前选中: +
+ + {value} +
+ +
+ )} +
+
+
+ ); +}; + +export default LucideIconSelect; + diff --git a/frontend/src/config/lucideIcons.ts b/frontend/src/config/lucideIcons.ts new file mode 100644 index 00000000..ddf261b8 --- /dev/null +++ b/frontend/src/config/lucideIcons.ts @@ -0,0 +1,104 @@ +import { icons, type LucideIcon } from 'lucide-react'; + +/** + * 获取所有 Lucide React 图标列表 + * @returns 图标名称和组件的数组 + */ +export const getAllLucideIcons = () => { + return Object.entries(icons).map(([name, component]) => ({ + name, + component: component as LucideIcon + })); +}; + +/** + * 根据名称获取 Lucide 图标组件 + * @param iconName 图标名称 + * @returns 图标组件或 null + */ +export const getLucideIcon = (iconName: string): LucideIcon | null => { + return (icons[iconName as keyof typeof icons] as LucideIcon) || null; +}; + +/** + * 常用图标分类 + */ +export const ICON_CATEGORIES = { + '常用': [ + 'FolderKanban', 'Workflow', 'Settings', 'Users', 'Calendar', + 'Clock', 'Database', 'Server', 'Cloud', 'Package' + ], + '文件与文档': [ + 'File', 'FileText', 'Folder', 'FolderOpen', 'Files', + 'FileJson', 'FileCode', 'FilePlus', 'FileEdit' + ], + '用户与团队': [ + 'User', 'Users', 'UserPlus', 'UserCheck', 'UserCog', + 'Shield', 'ShieldCheck', 'Contact' + ], + '开发与代码': [ + 'Code', 'Terminal', 'GitBranch', 'Github', 'GitFork', + 'GitCommit', 'GitPullRequest', 'Bug', 'Braces' + ], + '数据与存储': [ + 'Database', 'HardDrive', 'Save', 'Download', 'Upload', + 'Archive', 'Package', 'Inbox', 'FolderSync' + ], + '网络与服务': [ + 'Server', 'Cloud', 'CloudDownload', 'CloudUpload', 'Globe', + 'Wifi', 'Zap', 'Activity', 'TrendingUp' + ], + '时间与日历': [ + 'Calendar', 'CalendarDays', 'Clock', 'Timer', 'AlarmClock', + 'History', 'CalendarCheck', 'CalendarPlus' + ], + '操作与控制': [ + 'Play', 'Pause', 'Stop', 'RotateCw', 'RefreshCw', + 'Power', 'Settings', 'Sliders', 'ToggleLeft' + ], + '通知与提醒': [ + 'Bell', 'BellRing', 'MessageSquare', 'Mail', 'Inbox', + 'AlertCircle', 'AlertTriangle', 'Info', 'CheckCircle' + ], + '编辑与格式': [ + 'Edit', 'Edit2', 'Edit3', 'Pencil', 'Trash2', + 'Copy', 'Clipboard', 'Scissors', 'FileEdit' + ], + '导航与箭头': [ + 'ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'ChevronRight', + 'ChevronLeft', 'ChevronUp', 'ChevronDown', 'Home', 'Menu' + ], + '状态与标记': [ + 'Check', 'CheckCircle', 'CheckCircle2', 'X', 'XCircle', + 'AlertCircle', 'AlertTriangle', 'HelpCircle', 'Plus', 'Minus' + ] +}; + +/** + * 搜索图标 + * @param searchTerm 搜索关键词 + * @param category 分类(可选) + * @returns 匹配的图标列表 + */ +export const searchLucideIcons = (searchTerm: string, category?: string) => { + const allIcons = getAllLucideIcons(); + + let filtered = allIcons; + + // 按分类过滤 + if (category && category !== 'all') { + const categoryIcons = ICON_CATEGORIES[category as keyof typeof ICON_CATEGORIES] || []; + filtered = allIcons.filter(icon => categoryIcons.includes(icon.name)); + } + + // 按搜索词过滤 + if (searchTerm) { + const search = searchTerm.toLowerCase(); + filtered = filtered.filter(icon => + icon.name.toLowerCase().includes(search) + ); + } + + return filtered; +}; + diff --git a/frontend/src/pages/Workflow/Definition/components/CategoryManageDialog.tsx b/frontend/src/pages/Workflow/Definition/components/CategoryManageDialog.tsx new file mode 100644 index 00000000..4631b8af --- /dev/null +++ b/frontend/src/pages/Workflow/Definition/components/CategoryManageDialog.tsx @@ -0,0 +1,540 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { useForm } from "react-hook-form"; +import { + Plus, + Edit, + Trash2, + Search, + Loader2, + FolderKanban, + CheckCircle2, + XCircle, + Calendar, + MousePointerClick, + Clock +} from "lucide-react"; +import DynamicIcon from '@/components/DynamicIcon'; +import LucideIconSelect from '@/components/LucideIconSelect'; +import { + getWorkflowCategories, + createWorkflowCategory, + updateWorkflowCategory, + deleteWorkflowCategory +} from '../service'; +import type { + WorkflowCategoryResponse, + WorkflowCategoryRequest, + WorkflowCategoryQuery +} from '../types'; +import type { Page } from '@/types/base'; + +interface CategoryManageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +const CategoryManageDialog: React.FC = ({ + open, + onOpenChange, + onSuccess +}) => { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [data, setData] = useState | null>(null); + const [searchText, setSearchText] = useState(''); + const [editMode, setEditMode] = useState(false); + const [editRecord, setEditRecord] = useState(null); + const [iconSelectOpen, setIconSelectOpen] = useState(false); + + const form = useForm({ + defaultValues: { + name: '', + code: '', + description: '', + icon: '', + sort: 0, + supportedTriggers: [], + enabled: true, + } + }); + + // 加载分类列表 + const loadCategories = async () => { + setLoading(true); + try { + const query: WorkflowCategoryQuery = { + pageNum: 0, + pageSize: 100, + }; + if (searchText) { + query.name = searchText; + } + const result = await getWorkflowCategories(query); + setData(result); + } catch (error) { + console.error('加载分类失败:', error); + toast({ + variant: "destructive", + title: "加载失败", + description: "加载分类列表失败", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open) { + loadCategories(); + } + }, [open, searchText]); + + // 触发方式选项 + const triggerOptions = [ + { value: 'MANUAL', label: '手动触发', icon: MousePointerClick }, + { value: 'SCHEDULED', label: '定时触发', icon: Clock }, + { value: 'EVENT', label: '事件触发', icon: Calendar }, + ]; + + // 新建 + const handleCreate = () => { + setEditRecord(null); + form.reset({ + name: '', + code: '', + description: '', + icon: '', + sort: 0, + supportedTriggers: [], + enabled: true, + }); + setEditMode(true); + }; + + // 编辑 + const handleEdit = (record: WorkflowCategoryResponse) => { + setEditRecord(record); + form.reset({ + name: record.name, + code: record.code, + description: record.description || '', + icon: record.icon || '', + sort: record.sort, + supportedTriggers: record.supportedTriggers || [], + enabled: record.enabled, + }); + setEditMode(true); + }; + + // 删除 + const handleDelete = async (record: WorkflowCategoryResponse) => { + try { + await deleteWorkflowCategory(record.id); + toast({ + title: "删除成功", + description: `分类 "${record.name}" 已删除`, + }); + loadCategories(); + onSuccess?.(); + } catch (error) { + console.error('删除失败:', error); + toast({ + variant: "destructive", + title: "删除失败", + description: error instanceof Error ? error.message : '未知错误', + }); + } + }; + + // 保存 + const handleSave = async (values: WorkflowCategoryRequest) => { + try { + if (editRecord) { + await updateWorkflowCategory(editRecord.id, values); + toast({ + title: "更新成功", + description: `分类 "${values.name}" 已更新`, + }); + } else { + await createWorkflowCategory(values); + toast({ + title: "创建成功", + description: `分类 "${values.name}" 已创建`, + }); + } + setEditMode(false); + loadCategories(); + onSuccess?.(); + } catch (error) { + console.error('保存失败:', error); + toast({ + variant: "destructive", + title: "保存失败", + description: error instanceof Error ? error.message : '未知错误', + }); + } + }; + + // 取消编辑 + const handleCancel = () => { + setEditMode(false); + setEditRecord(null); + form.reset(); + }; + + // 渲染触发方式 + const renderTriggers = (triggers?: string[]) => { + if (!triggers || triggers.length === 0) { + return -; + } + return ( +
+ {triggers.map(trigger => { + const option = triggerOptions.find(o => o.value === trigger); + if (!option) return null; + const Icon = option.icon; + return ( + + + {option.label} + + ); + })} +
+ ); + }; + + return ( + + + + + + 分类管理 + + + + {!editMode ? ( +
+ {/* 搜索和新建 */} +
+
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* 分类列表 */} +
+ + + + 分类名称 + 分类代码 + 图标 + 支持触发方式 + 排序 + 状态 + 描述 + 操作 + + + + {loading ? ( + + + + + + ) : data?.content && data.content.length > 0 ? ( + data.content.map((record) => ( + + {record.name} + + + {record.code} + + + + {record.icon ? ( +
+ +
+ ) : ( + - + )} +
+ + {renderTriggers(record.supportedTriggers)} + + {record.sort} + + + {record.enabled ? ( + <> + + 启用 + + ) : ( + <> + + 禁用 + + )} + + + + {record.description || '-'} + + +
+ + +
+
+
+ )) + ) : ( + + + 暂无分类数据 + + + )} +
+
+
+
+ ) : ( + /* 编辑表单 */ +
+ +
+ ( + + 分类名称 + + + + + + )} + /> + + ( + + 分类代码 + + + + + + )} + /> + + ( + + 图标 + +
+ setIconSelectOpen(true)} + className="cursor-pointer" + /> + {field.value && ( +
+ +
+ )} +
+
+ + +
+ )} + /> + + ( + + 排序 + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> +
+ + ( + + 支持的触发方式 +
+ {triggerOptions.map((option) => { + const Icon = option.icon; + const isSelected = field.value?.includes(option.value); + return ( + + ); + })} +
+ +
+ )} + /> + + ( + + 描述 + +