This commit is contained in:
dengqichen 2025-10-21 13:18:50 +08:00
parent e18874688e
commit fd0b615e6b
8 changed files with 2177 additions and 1244 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,37 +12,28 @@
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "^2.8.2",
"@antv/layout": "^1.2.14-beta.8",
"@antv/x6": "^2.18.1",
"@antv/x6-plugin-clipboard": "^2.1.6",
"@antv/x6-plugin-export": "^2.1.6",
"@antv/x6-plugin-history": "^2.2.4",
"@antv/x6-plugin-keyboard": "^2.2.3",
"@antv/x6-plugin-minimap": "^2.0.7",
"@antv/x6-plugin-selection": "^2.2.2",
"@antv/x6-plugin-snapline": "^2.1.7",
"@antv/x6-plugin-transform": "^2.1.8",
"@antv/x6-react-shape": "^2.2.3",
"@formily/antd-v5": "^1.2.3",
"@formily/core": "^2.3.2",
"@formily/react": "^2.3.2",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@reduxjs/toolkit": "^2.0.1",
"@types/recharts": "^1.8.29",
"@xyflow/react": "^12.8.6",

View File

@ -0,0 +1,57 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -1,5 +1,14 @@
import React, { useEffect } from 'react';
import { Modal, Form, Input, InputNumber, Radio, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
import type { FlowEdge } from '../types';
interface EdgeConfigModalProps {
@ -15,9 +24,31 @@ export interface EdgeCondition {
priority: number;
}
// Zod 表单验证 Schema
const edgeConditionSchema = z.object({
type: z.enum(['EXPRESSION', 'DEFAULT'], {
required_error: '请选择条件类型',
}),
expression: z.string().optional(),
priority: z.number()
.min(1, '优先级最小为 1')
.max(999, '优先级最大为 999'),
}).refine((data) => {
// 如果是表达式类型expression 必填
if (data.type === 'EXPRESSION') {
return data.expression && data.expression.trim().length > 0;
}
return true;
}, {
message: '请输入条件表达式',
path: ['expression'],
});
type EdgeConditionFormValues = z.infer<typeof edgeConditionSchema>;
/**
*
* Workflow/Design/ExpressionModal
* 使 shadcn/ui Dialog + react-hook-form
*/
const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
visible,
@ -25,123 +56,178 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
onOk,
onCancel
}) => {
const [form] = Form.useForm();
const { toast } = useToast();
const [conditionType, setConditionType] = useState<'EXPRESSION' | 'DEFAULT'>('EXPRESSION');
// 当edge变化时更新表单初始值
const form = useForm<EdgeConditionFormValues>({
resolver: zodResolver(edgeConditionSchema),
defaultValues: {
type: 'EXPRESSION',
expression: '',
priority: 10,
},
});
// 当 edge 变化时,更新表单值
useEffect(() => {
if (visible && edge) {
const condition = edge.data?.condition;
form.setFieldsValue({
type: condition?.type || 'EXPRESSION',
const values = {
type: (condition?.type || 'EXPRESSION') as 'EXPRESSION' | 'DEFAULT',
expression: condition?.expression || '',
priority: condition?.priority || 10
});
priority: condition?.priority || 10,
};
form.reset(values);
setConditionType(values.type);
}
}, [visible, edge, form]);
const handleOk = async () => {
const handleSubmit = (values: EdgeConditionFormValues) => {
if (!edge) return;
try {
const values = await form.validateFields();
// 验证表达式
if (values.type === 'EXPRESSION') {
if (!values.expression || values.expression.trim() === '') {
message.error('请输入条件表达式');
return;
}
// 检查是否包含变量引用 ${...}
// 检查表达式是否包含变量引用
if (values.type === 'EXPRESSION' && values.expression) {
const hasVariable = /\$\{[\w.]+\}/.test(values.expression);
if (!hasVariable) {
message.warning('表达式建议包含变量引用,格式:${变量名}');
toast({
title: '提示',
description: '表达式建议包含变量引用,格式:${变量名}',
});
}
}
onOk(edge.id, values);
} catch (error) {
console.error('表单验证失败:', error);
}
handleClose();
};
const handleCancel = () => {
form.resetFields();
const handleClose = () => {
form.reset();
setConditionType('EXPRESSION');
onCancel();
};
return (
<Modal
title="配置边条件"
open={visible}
onOk={handleOk}
onCancel={handleCancel}
width={600}
okText="确定"
cancelText="取消"
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'EXPRESSION',
expression: '',
priority: 10
}}
>
<Form.Item
name="type"
label="条件类型"
rules={[{ required: true, message: '请选择条件类型' }]}
>
<Radio.Group>
<Radio value="EXPRESSION"></Radio>
<Radio value="DEFAULT"></Radio>
</Radio.Group>
</Form.Item>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
>
{({ getFieldValue }) => {
const type = getFieldValue('type');
return type === 'EXPRESSION' ? (
<Form.Item
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel></FormLabel>
<FormControl>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<input
type="radio"
id="type-expression"
value="EXPRESSION"
checked={field.value === 'EXPRESSION'}
onChange={(e) => {
field.onChange(e.target.value);
setConditionType('EXPRESSION');
}}
className="h-4 w-4"
/>
<Label htmlFor="type-expression" className="cursor-pointer font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="type-default"
value="DEFAULT"
checked={field.value === 'DEFAULT'}
onChange={(e) => {
field.onChange(e.target.value);
setConditionType('DEFAULT');
}}
className="h-4 w-4"
/>
<Label htmlFor="type-default" className="cursor-pointer font-normal">
</Label>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{conditionType === 'EXPRESSION' ? (
<FormField
control={form.control}
name="expression"
label="条件表达式"
rules={[{ required: true, message: '请输入条件表达式' }]}
extra="支持使用 ${变量名} 引用流程变量,例如:${amount} > 1000"
>
<Input.TextArea
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="请输入条件表达式,如:${amount} > 1000"
rows={4}
autoComplete="off"
{...field}
/>
</FormControl>
<FormDescription>
使 {'${变量名}'} {'${amount} > 1000'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Form.Item>
) : (
<div className="text-sm text-gray-500 mb-4">
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
</div>
);
}}
</Form.Item>
)}
<Form.Item
<FormField
control={form.control}
name="priority"
label="优先级"
rules={[{ required: true, message: '请设置优先级' }]}
extra="数字越小优先级越高1-999"
>
<InputNumber
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={999}
className="w-full"
placeholder="请输入优先级"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</Form.Item>
</FormControl>
<FormDescription>
1-999
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
</Button>
<Button type="submit">
</Button>
</DialogFooter>
</form>
</Form>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Drawer, Tabs, Button, Space, message } from 'antd';
import { SaveOutlined, ReloadOutlined, CloseOutlined } from '@ant-design/icons';
import { FormItem, Input, NumberPicker, Select, FormLayout, Switch } from '@formily/antd-v5';
import { createForm } from '@formily/core';
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
import { Save, RotateCcw } from 'lucide-react';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import type { FlowNode, FlowNodeData } from '../types';
import type { WorkflowNodeDefinition } from '../nodes/types';
import { isConfigurableNode } from '../nodes/types';
@ -35,7 +38,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
onOk
}) => {
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('config');
const { toast } = useToast();
// 获取节点定义
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
@ -118,11 +121,18 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
};
onOk(node.id, updatedData);
message.success('节点配置保存成功');
toast({
title: "保存成功",
description: "节点配置已成功保存",
});
onCancel();
} catch (error) {
if (error instanceof Error) {
message.error(error.message);
toast({
variant: "destructive",
title: "保存失败",
description: error.message,
});
}
} finally {
setLoading(false);
@ -133,7 +143,10 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
configForm.reset();
inputForm.reset();
outputForm.reset();
message.info('已重置为初始值');
toast({
title: "已重置",
description: "表单已重置为初始值",
});
};
// 将JSON Schema转换为Formily Schema扩展配置
@ -248,120 +261,111 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
return null;
}
// 构建Tabs配置
const tabItems = [
{
key: 'config',
label: '基本配置',
children: activeTab === 'config' ? (
<div style={{ padding: '16px 0' }}>
return (
<Sheet open={visible} onOpenChange={(open) => !open && onCancel()}>
<SheetContent className="w-[720px] sm:max-w-[720px] flex flex-col p-0">
{/* Header - 固定在顶部 */}
<div className="px-6 pt-6 pb-4 border-b">
<SheetHeader className="space-y-2">
<SheetTitle className="text-xl font-semibold">
- {nodeDefinition.nodeName}
</SheetTitle>
<SheetDescription className="text-sm text-muted-foreground">
</SheetDescription>
</SheetHeader>
</div>
{/* Content - 可滚动区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* 使用 Accordion 支持多个面板同时展开,默认展开基本配置 */}
<Accordion
type="multiple"
defaultValue={["config"]}
className="w-full"
>
{/* 基本配置 - 始终显示 */}
<AccordionItem value="config">
<AccordionTrigger className="text-base font-semibold">
</AccordionTrigger>
<AccordionContent className="px-1">
<FormProvider form={configForm}>
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
<FormLayout layout="vertical" colon={false}>
<SchemaField schema={convertToFormilySchema(nodeDefinition.configSchema)} />
</FormLayout>
</FormProvider>
</div>
) : null,
},
];
</AccordionContent>
</AccordionItem>
// 如果是可配置节点添加输入和输出映射TAB
if (isConfigurableNode(nodeDefinition)) {
if (nodeDefinition.inputMappingSchema) {
tabItems.push({
key: 'input',
label: '输入映射',
children: activeTab === 'input' ? (
<div style={{ padding: '16px 0' }}>
{/* 输入映射 - 条件显示 */}
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
<AccordionItem value="input">
<AccordionTrigger className="text-base font-semibold">
</AccordionTrigger>
<AccordionContent className="px-1">
<FormProvider form={inputForm}>
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
<FormLayout layout="vertical" colon={false}>
<SchemaField schema={convertToFormilySchema(nodeDefinition.inputMappingSchema)} />
</FormLayout>
</FormProvider>
</div>
) : null,
});
}
</AccordionContent>
</AccordionItem>
)}
if (nodeDefinition.outputMappingSchema) {
tabItems.push({
key: 'output',
label: '输出映射',
children: activeTab === 'output' ? (
<div style={{ padding: '16px 0' }}>
{/* 输出映射 - 条件显示 */}
{isConfigurableNode(nodeDefinition) && nodeDefinition.outputMappingSchema && (
<AccordionItem value="output">
<AccordionTrigger className="text-base font-semibold">
</AccordionTrigger>
<AccordionContent className="px-1">
<FormProvider form={outputForm}>
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
<FormLayout layout="vertical" colon={false}>
<SchemaField schema={convertToFormilySchema(nodeDefinition.outputMappingSchema)} />
</FormLayout>
</FormProvider>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
</div>
) : null,
});
}
}
return (
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: '24px' }}>
<span style={{ fontSize: '16px', fontWeight: '600' }}>
- {nodeDefinition.nodeName}
</span>
{/* Footer - 固定在底部 */}
<div className="px-6 py-4 border-t bg-background">
<SheetFooter className="flex-row justify-between gap-2">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onCancel}
size="small"
/>
</div>
}
placement="right"
width={720}
open={visible}
onClose={onCancel}
closeIcon={null}
styles={{
body: { padding: '0 24px 24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
}}
footer={
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: '12px 0',
borderTop: '1px solid #f0f0f0'
}}>
<Button
icon={<ReloadOutlined />}
type="button"
variant="outline"
onClick={handleReset}
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Space>
<Button onClick={onCancel}>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
>
</Button>
<Button
type="primary"
loading={loading}
icon={<SaveOutlined />}
type="button"
onClick={handleSubmit}
disabled={loading}
className="gap-2"
>
<Save className="h-4 w-4" />
{loading ? '保存中...' : '保存配置'}
</Button>
</Space>
</div>
}
>
<div style={{ paddingTop: '16px' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
size="large"
/>
</SheetFooter>
</div>
</Drawer>
</SheetContent>
</Sheet>
);
};

View File

@ -1,14 +1,13 @@
import React from 'react';
import { Button, Divider, Tooltip } from 'antd';
import { Save, Undo, Redo, ZoomIn, ZoomOut, Maximize2, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
SaveOutlined,
UndoOutlined,
RedoOutlined,
ZoomInOutlined,
ZoomOutOutlined,
ExpandOutlined,
ArrowLeftOutlined
} from '@ant-design/icons';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface WorkflowToolbarProps {
title?: string;
@ -40,131 +39,124 @@ const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
className = ''
}) => {
return (
<TooltipProvider>
<div
className={`workflow-toolbar ${className}`}
style={{
height: '56px',
background: 'white',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
className={`workflow-toolbar flex items-center justify-between h-14 px-4 bg-background border-b shadow-sm ${className}`}
>
{/* 左侧:返回按钮和标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Tooltip title="返回列表">
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<ArrowLeftOutlined />}
variant="ghost"
size="icon"
onClick={onBack}
style={{ color: '#6b7280' }}
/>
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Divider type="vertical" />
<h2 style={{
margin: 0,
fontSize: '16px',
fontWeight: '600',
color: '#374151'
}}>
<Separator orientation="vertical" className="h-6" />
<h2 className="text-base font-semibold text-foreground m-0">
{title}
</h2>
</div>
{/* 右侧:操作按钮区域 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div className="flex items-center gap-1.5">
{/* 撤销/重做 */}
<Tooltip title="撤销 (Ctrl+Z)">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<UndoOutlined />}
variant="ghost"
size="icon"
onClick={onUndo}
disabled={!canUndo}
size="middle"
style={{
color: canUndo ? '#374151' : '#d1d5db',
}}
/>
>
<Undo className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent> (Ctrl+Z)</TooltipContent>
</Tooltip>
<Tooltip title="重做 (Ctrl+Shift+Z)">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<RedoOutlined />}
variant="ghost"
size="icon"
onClick={onRedo}
disabled={!canRedo}
size="middle"
style={{
color: canRedo ? '#374151' : '#d1d5db',
}}
/>
>
<Redo className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent> (Ctrl+Shift+Z)</TooltipContent>
</Tooltip>
<Divider type="vertical" style={{ margin: '0 4px' }} />
<Separator orientation="vertical" className="h-6 mx-1" />
{/* 视图操作 */}
<Tooltip title="放大 (+)">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<ZoomInOutlined />}
variant="ghost"
size="icon"
onClick={onZoomIn}
size="middle"
/>
>
<ZoomIn className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent> (+)</TooltipContent>
</Tooltip>
<Tooltip title="缩小 (-)">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<ZoomOutOutlined />}
variant="ghost"
size="icon"
onClick={onZoomOut}
size="middle"
/>
>
<ZoomOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent> (-)</TooltipContent>
</Tooltip>
<Tooltip title="适应视图">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<ExpandOutlined />}
variant="ghost"
size="icon"
onClick={onFitView}
size="middle"
/>
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
{/* 缩放比例显示 */}
<div style={{
background: '#f8fafc',
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
color: '#475569',
fontFamily: 'ui-monospace, monospace',
minWidth: '60px',
textAlign: 'center',
fontWeight: '600',
border: '1px solid #e2e8f0',
marginLeft: '4px'
}}>
<div className="ml-1 px-3 py-1.5 bg-muted text-muted-foreground text-xs font-mono font-semibold rounded-md border min-w-[60px] text-center">
{Math.round(zoom * 100)}%
</div>
<Divider type="vertical" style={{ margin: '0 8px' }} />
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 保存按钮(最右侧) */}
<Button
type="primary"
icon={<SaveOutlined />}
onClick={onSave}
size="middle"
style={{
borderRadius: '6px',
fontWeight: '500',
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.2)',
}}
className="gap-1.5 font-medium shadow-md"
>
<Save className="h-4 w-4" />
</Button>
</div>
</div>
</TooltipProvider>
);
};