feat: support settings menu level configure

This commit is contained in:
gchust 2025-06-25 15:24:11 +08:00
parent d6d9e213dc
commit a8546e3599

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Dropdown, Modal, App } from 'antd';
import type { MenuProps } from 'antd';
import {
@ -21,74 +21,196 @@ import { FlowModel } from '../../../../models';
import { ActionStepDefinition } from '../../../../types';
import { openStepSettings } from './StepSettings';
// Type definitions for better type safety
interface StepInfo {
stepKey: string;
step: ActionStepDefinition;
uiSchema: Record<string, any>;
title: string;
modelKey?: string;
}
interface FlowInfo {
flow: any;
steps: StepInfo[];
modelKey?: string;
}
interface MenuConfig {
maxDepth?: number;
enablePerformanceOptimization?: boolean;
}
/**
* Find sub-model by key with validation
* Supports formats: subKey or subKey[index]
*/
const findSubModelByKey = (model: FlowModel, subModelKey: string): FlowModel | null => {
// Input validation
if (!model || !subModelKey || typeof subModelKey !== 'string') {
console.warn('Invalid input parameters');
return null;
}
// Parse subKey[index] format
const match = subModelKey.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\[(\d+)\])?$/);
if (!match) {
console.warn(`Invalid subModelKey format: ${subModelKey}`);
return null;
}
const [, subKey, indexStr] = match;
const subModel = model.subModels?.[subKey];
if (!subModel) {
console.warn(`SubModel '${subKey}' not found`);
return null;
}
if (indexStr !== undefined) {
// Array type sub-model
const index = parseInt(indexStr, 10);
if (!Array.isArray(subModel)) {
console.warn(`Expected array for '${subKey}', got ${typeof subModel}`);
return null;
}
if (index < 0 || index >= subModel.length) {
console.warn(`Array index ${index} out of bounds for '${subKey}'`);
return null;
}
return subModel[index] instanceof FlowModel ? subModel[index] : null;
} else {
// Object type sub-model
if (Array.isArray(subModel)) {
console.warn(`Expected object for '${subKey}', got array`);
return null;
}
return subModel instanceof FlowModel ? subModel : null;
}
};
/**
*
*
*/
export const DefaultSettingsIcon: React.FC<{
interface DefaultSettingsIconProps {
model: FlowModel;
showDeleteButton?: boolean;
showCopyUidButton?: boolean;
[key: string]: any; // 允许额外的 props
}> = ({ model, showDeleteButton = true, showCopyUidButton = true }) => {
menuLevels?: number; // Menu levels: 1=current model only (default), 2=include sub-models
flattenSubMenus?: boolean; // Whether to flatten sub-menus: false=group by model (default), true=flatten all
[key: string]: any; // Allow additional props
}
export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
model,
showDeleteButton = true,
showCopyUidButton = true,
menuLevels = 1, // 默认一级菜单
flattenSubMenus = true,
}) => {
const { message } = App.useApp();
// 分离处理函数以便更好的代码组织
const handleCopyUid = useCallback(async () => {
try {
await navigator.clipboard.writeText(model.uid);
message.success('UID 已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
message.error('复制失败,请重试');
}
}, [model.uid, message]);
const handleDelete = useCallback(() => {
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: '确定要删除此项吗?此操作不可撤销。',
okText: '确认删除',
okType: 'primary',
cancelText: '取消',
async onOk() {
try {
await model.destroy();
} catch (error) {
console.error('删除操作失败:', error);
Modal.error({
title: '删除失败',
content: '删除操作失败,请检查控制台获取详细信息。',
});
}
},
});
}, [model]);
const handleStepConfiguration = useCallback(
(key: string) => {
const keyParts = key.split(':');
if (keyParts.length < 2 || keyParts.length > 3) {
console.error('Invalid configuration key format:', key);
return;
}
let targetModel = model;
let flowKey: string;
let stepKey: string;
if (keyParts.length === 3) {
// Sub-model configuration: subModelKey:flowKey:stepKey
const [subModelKey, subFlowKey, subStepKey] = keyParts;
flowKey = subFlowKey;
stepKey = subStepKey;
const subModel = findSubModelByKey(model, subModelKey);
if (!subModel) {
console.error(`Sub-model '${subModelKey}' not found`);
return;
}
targetModel = subModel;
} else {
// Current model configuration: flowKey:stepKey
[flowKey, stepKey] = keyParts;
}
try {
openStepSettings({
model: targetModel,
flowKey,
stepKey,
});
} catch (error) {
console.log('配置弹窗已取消或出错:', error);
}
},
[model],
);
const handleMenuClick = useCallback(
({ key }: { key: string }) => {
if (key === 'copy-uid') {
// 处理复制 uid 操作
navigator.clipboard
.writeText(model.uid)
.then(() => {
message.success('UID 已复制到剪贴板');
})
.catch((error) => {
console.error('复制失败:', error);
message.error('复制失败,请重试');
});
} else if (key === 'delete') {
// 处理删除操作
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: '确定要删除此项吗?此操作不可撤销。',
okText: '确认删除',
okType: 'primary',
cancelText: '取消',
async onOk() {
try {
await model.destroy();
} catch (error) {
console.error('删除操作失败:', error);
Modal.error({
title: '删除失败',
content: '删除操作失败,请检查控制台获取详细信息。',
});
}
},
});
} else {
// 处理step配置key格式为 "flowKey:stepKey"
const [flowKey, stepKey] = key.split(':');
try {
openStepSettings({
model,
flowKey,
stepKey,
});
} catch (error) {
// 用户取消或出错,静默处理
console.log('配置弹窗已取消或出错:', error);
}
// Handle duplicate key suffixes (e.g., "key-1" -> "key")
const cleanKey = key.includes('-') && /^(.+)-\d+$/.test(key) ? key.replace(/-\d+$/, '') : key;
switch (cleanKey) {
case 'copy-uid':
handleCopyUid();
break;
case 'delete':
handleDelete();
break;
default:
handleStepConfiguration(cleanKey);
break;
}
},
[model, message],
[handleCopyUid, handleDelete, handleStepConfiguration],
);
// 获取可配置的flows和steps
const getConfigurableFlowsAndSteps = useCallback(() => {
// 获取单个模型的可配置flows和steps
const getModelConfigurableFlowsAndSteps = useCallback((targetModel: FlowModel, modelKey?: string): FlowInfo[] => {
try {
const ModelClass = model.constructor as typeof FlowModel;
const ModelClass = targetModel.constructor as typeof FlowModel;
const flows = ModelClass.getFlows();
const flowsArray = Array.from(flows.values());
@ -110,9 +232,13 @@ export const DefaultSettingsIcon: React.FC<{
// 如果step使用了action也获取action的uiSchema
let actionUiSchema = {};
if (actionStep.use) {
const action = model.flowEngine?.getAction?.(actionStep.use);
if (action && action.uiSchema) {
actionUiSchema = action.uiSchema;
try {
const action = targetModel.flowEngine?.getAction?.(actionStep.use);
if (action && action.uiSchema) {
actionUiSchema = action.uiSchema;
}
} catch (error) {
console.warn(`获取action '${actionStep.use}' 失败:`, error);
}
}
@ -120,13 +246,19 @@ export const DefaultSettingsIcon: React.FC<{
const mergedUiSchema = { ...actionUiSchema };
// 将stepUiSchema中的字段合并到mergedUiSchema
Object.entries(stepUiSchema).forEach(([fieldKey, schema]) => {
if (mergedUiSchema[fieldKey]) {
mergedUiSchema[fieldKey] = { ...mergedUiSchema[fieldKey], ...schema };
} else {
mergedUiSchema[fieldKey] = schema;
}
});
try {
Object.entries(stepUiSchema).forEach(([fieldKey, schema]) => {
if (typeof fieldKey === 'string' && schema) {
if (mergedUiSchema[fieldKey]) {
mergedUiSchema[fieldKey] = { ...mergedUiSchema[fieldKey], ...schema };
} else {
mergedUiSchema[fieldKey] = schema;
}
}
});
} catch (error) {
console.warn(`合并步骤 '${stepKey}' 的uiSchema时出错:`, error);
}
// 如果没有可配置的UI Schema返回null
if (Object.keys(mergedUiSchema).length === 0) {
@ -138,81 +270,227 @@ export const DefaultSettingsIcon: React.FC<{
step: actionStep,
uiSchema: mergedUiSchema,
title: actionStep.title || stepKey,
modelKey, // 添加模型标识
};
})
.filter(Boolean);
return configurableSteps.length > 0 ? { flow, steps: configurableSteps } : null;
return configurableSteps.length > 0 ? ({ flow, steps: configurableSteps, modelKey } as FlowInfo) : null;
})
.filter(Boolean);
} catch (error) {
console.warn('[DefaultSettingsIcon] 获取可配置flows失败:', error);
console.error(`获取模型 '${targetModel?.uid || 'unknown'}' 的可配置flows失败:`, error);
return [];
}
}, [model]);
}, []);
// 获取可配置的flows和steps
const getConfigurableFlowsAndSteps = useCallback((): FlowInfo[] => {
const result: FlowInfo[] = [];
const processedModels = new Set<string>(); // 防止循环引用
const processModel = (targetModel: FlowModel, depth: number, modelKey?: string) => {
// 限制递归深度为menuLevels
if (depth > menuLevels) {
return;
}
// 防止循环引用
const modelId = targetModel.uid || `temp-${Date.now()}`;
if (processedModels.has(modelId)) {
return;
}
processedModels.add(modelId);
try {
const modelFlows = getModelConfigurableFlowsAndSteps(targetModel, modelKey);
result.push(...modelFlows);
// 如果需要,处理子模型
if (depth < menuLevels && targetModel.subModels) {
Object.entries(targetModel.subModels).forEach(([subKey, subModelValue]) => {
if (Array.isArray(subModelValue)) {
subModelValue.forEach((subModel, index) => {
if (subModel instanceof FlowModel && index < 50) {
// 合理的限制
processModel(subModel, depth + 1, `${subKey}[${index}]`);
}
});
} else if (subModelValue instanceof FlowModel) {
processModel(subModelValue, depth + 1, subKey);
}
});
}
} finally {
processedModels.delete(modelId);
}
};
processModel(model, 1);
return result;
}, [model, menuLevels, getModelConfigurableFlowsAndSteps]);
const configurableFlowsAndSteps = getConfigurableFlowsAndSteps();
// 构建菜单项,包含错误处理和记忆化
const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
const items: NonNullable<MenuProps['items']> = [];
const keyCounter = new Map<string, number>(); // 跟踪重复的key
// 生成唯一key的辅助函数
const generateUniqueKey = (baseKey: string): string => {
const count = keyCounter.get(baseKey) || 0;
keyCounter.set(baseKey, count + 1);
return count === 0 ? baseKey : `${baseKey}-${count}`;
};
// 添加flows和steps配置项
if (configurableFlowsAndSteps.length > 0) {
if (flattenSubMenus) {
// 平铺模式:只有流程分组,没有模型层级
configurableFlowsAndSteps.forEach(({ flow, steps, modelKey }: FlowInfo) => {
const groupKey = generateUniqueKey(`flow-group-${modelKey ? `${modelKey}-` : ''}${flow.key}`);
// 在平铺模式下始终按流程分组
items.push({
key: groupKey,
label: flow.title || flow.key,
type: 'group',
});
steps.forEach((stepInfo: StepInfo) => {
// 构建菜单项key为子模型包含modelKey
const baseMenuKey = modelKey
? `${modelKey}:${flow.key}:${stepInfo.stepKey}`
: `${flow.key}:${stepInfo.stepKey}`;
const uniqueKey = generateUniqueKey(baseMenuKey);
items.push({
key: uniqueKey,
icon: <SettingOutlined />,
label: stepInfo.title,
});
});
});
} else {
// 层级模式:真正的子菜单结构
const modelGroups = new Map<string, FlowInfo[]>();
// 按模型分组flows
configurableFlowsAndSteps.forEach((flowInfo) => {
const modelKey = flowInfo.modelKey || 'current';
if (!modelGroups.has(modelKey)) {
modelGroups.set(modelKey, []);
}
const group = modelGroups.get(modelKey);
if (group) {
group.push(flowInfo);
}
});
// 构建层级菜单结构
modelGroups.forEach((flows, modelKey) => {
if (modelKey === 'current') {
// 直接添加当前模型的flows
flows.forEach(({ flow, steps }: FlowInfo) => {
const groupKey = generateUniqueKey(`flow-group-${flow.key}`);
items.push({
key: groupKey,
label: flow.title || flow.key,
type: 'group',
});
steps.forEach((stepInfo: StepInfo) => {
const uniqueKey = generateUniqueKey(`${flow.key}:${stepInfo.stepKey}`);
items.push({
key: uniqueKey,
icon: <SettingOutlined />,
label: stepInfo.title,
});
});
});
} else {
// 为子模型创建子菜单
const subMenuKey = generateUniqueKey(`sub-menu-${modelKey}`);
const subMenuChildren: any[] = [];
flows.forEach(({ flow, steps }: FlowInfo) => {
steps.forEach((stepInfo: StepInfo) => {
const uniqueKey = generateUniqueKey(`${modelKey}:${flow.key}:${stepInfo.stepKey}`);
subMenuChildren.push({
key: uniqueKey,
icon: <SettingOutlined />,
label: stepInfo.title,
});
});
});
items.push({
key: subMenuKey,
label: modelKey,
children: subMenuChildren,
});
}
});
}
}
return items;
}, [configurableFlowsAndSteps, flattenSubMenus]);
// 向菜单项添加额外按钮
const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
const items = [...menuItems];
if (showCopyUidButton || showDeleteButton) {
// 如果有flows配置项添加分割线
if (configurableFlowsAndSteps.length > 0) {
items.push({
type: 'divider' as const,
});
}
// 添加复制uid按钮
if (showCopyUidButton && model.uid) {
items.push({
key: 'copy-uid',
icon: <CopyOutlined />,
label: '复制 UID',
});
}
// 添加删除按钮
if (showDeleteButton && typeof model.destroy === 'function') {
items.push({
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
});
}
}
return items;
}, [menuItems, showCopyUidButton, showDeleteButton, configurableFlowsAndSteps.length, model.uid, model.destroy]);
// 如果没有可配置的flows且不显示删除按钮和复制UID按钮不显示菜单
if (configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton) {
return null;
}
// 构建菜单项
const menuItems: MenuProps['items'] = [];
// 添加flows和steps配置项
if (configurableFlowsAndSteps.length > 0) {
configurableFlowsAndSteps.forEach(({ flow, steps }) => {
// 始终按flow分组显示
menuItems.push({
key: `flow-group-${flow.key}`,
label: flow.title || flow.key,
type: 'group',
});
steps.forEach((stepInfo) => {
menuItems.push({
key: `${flow.key}:${stepInfo.stepKey}`,
icon: <SettingOutlined />,
label: stepInfo.title,
});
});
});
}
// 添加分割线、复制 uid 和删除按钮
if (showCopyUidButton || showDeleteButton) {
// 如果有flows配置项添加分割线
if (configurableFlowsAndSteps.length > 0) {
menuItems.push({
type: 'divider' as const,
});
}
// 添加复制 uid 按钮
if (showCopyUidButton) {
menuItems.push({
key: 'copy-uid',
icon: <CopyOutlined />,
label: '复制 UID',
});
}
// 添加删除按钮
if (showDeleteButton) {
menuItems.push({
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
});
}
// 渲染前验证模型
if (!model || !model.uid) {
console.warn('提供的模型无效');
return null;
}
return (
<Dropdown
menu={{
items: menuItems,
items: finalMenuItems,
onClick: handleMenuClick,
}}
trigger={['hover']}