重构前端逻辑
This commit is contained in:
parent
11c44bc95d
commit
fcdf005e22
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@ -80,24 +80,26 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||
// 节点变化处理
|
||||
const handleNodesChange = useCallback((changes: any) => {
|
||||
onNodesStateChange(changes);
|
||||
if (onNodesChange) {
|
||||
// 延迟获取最新状态 - 在实际项目中可以使用useEffect监听nodes变化
|
||||
setTimeout(() => {
|
||||
onNodesChange(nodes as FlowNode[]);
|
||||
}, 0);
|
||||
}
|
||||
}, [onNodesStateChange, onNodesChange, nodes]);
|
||||
}, [onNodesStateChange]);
|
||||
|
||||
// 边变化处理
|
||||
const handleEdgesChange = useCallback((changes: any) => {
|
||||
onEdgesStateChange(changes);
|
||||
if (onEdgesChange) {
|
||||
// 延迟获取最新状态 - 在实际项目中可以使用useEffect监听edges变化
|
||||
setTimeout(() => {
|
||||
onEdgesChange(edges as FlowEdge[]);
|
||||
}, 0);
|
||||
}, [onEdgesStateChange]);
|
||||
|
||||
// 使用 useEffect 监听 nodes 变化并通知父组件
|
||||
useEffect(() => {
|
||||
if (onNodesChange && nodes.length > 0) {
|
||||
onNodesChange(nodes as FlowNode[]);
|
||||
}
|
||||
}, [onEdgesStateChange, onEdgesChange, edges]);
|
||||
}, [nodes, onNodesChange]);
|
||||
|
||||
// 使用 useEffect 监听 edges 变化并通知父组件
|
||||
useEffect(() => {
|
||||
if (onEdgesChange) {
|
||||
onEdgesChange(edges as FlowEdge[]);
|
||||
}
|
||||
}, [edges, onEdgesChange]);
|
||||
|
||||
// 连接验证 - 利用 React Flow 的实时验证功能
|
||||
const isValidConnection = useCallback((connection: Connection | Edge) => {
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
// 基本操作
|
||||
{ keys: ['Ctrl', 'S'], description: '保存工作流', category: '基本操作' },
|
||||
{ keys: ['Ctrl', 'Z'], description: '撤销', category: '基本操作' },
|
||||
{ keys: ['Ctrl', 'Y'], description: '重做', category: '基本操作' },
|
||||
{ keys: ['Ctrl', 'Shift', 'Z'], description: '重做', category: '基本操作' },
|
||||
{ keys: ['?'], description: '显示快捷键', category: '基本操作' },
|
||||
|
||||
// 编辑操作
|
||||
{ keys: ['Ctrl', 'C'], description: '复制选中节点', category: '编辑操作' },
|
||||
{ keys: ['Ctrl', 'V'], description: '粘贴节点', category: '编辑操作' },
|
||||
{ keys: ['Ctrl', 'A'], description: '全选节点', category: '编辑操作' },
|
||||
{ keys: ['Delete'], description: '删除选中节点', category: '编辑操作' },
|
||||
{ keys: ['Backspace'], description: '删除选中节点', category: '编辑操作' },
|
||||
|
||||
// 视图操作
|
||||
{ keys: ['Ctrl', '+'], description: '放大画布', category: '视图操作' },
|
||||
{ keys: ['Ctrl', '-'], description: '缩小画布', category: '视图操作' },
|
||||
{ keys: ['Ctrl', '0'], description: '适应视图', category: '视图操作' },
|
||||
{ keys: ['Space', '+', '拖动'], description: '平移画布', category: '视图操作' },
|
||||
|
||||
// 节点操作
|
||||
{ keys: ['双击节点'], description: '配置节点', category: '节点操作' },
|
||||
{ keys: ['双击边'], description: '配置连接条件', category: '节点操作' },
|
||||
{ keys: ['Ctrl', '+', '点击'], description: '多选节点', category: '节点操作' },
|
||||
];
|
||||
|
||||
const KeyboardShortcutsPanel: React.FC<KeyboardShortcutsPanelProps> = ({ open, onClose }) => {
|
||||
// 按分类分组快捷键
|
||||
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
|
||||
if (!acc[shortcut.category]) {
|
||||
acc[shortcut.category] = [];
|
||||
}
|
||||
acc[shortcut.category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, Shortcut[]>);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
键盘快捷键
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{Object.entries(groupedShortcuts).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 pb-2 border-b">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((shortcut, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">{shortcut.description}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, keyIndex) => (
|
||||
<React.Fragment key={keyIndex}>
|
||||
{keyIndex > 0 && (
|
||||
<span className="text-gray-400 text-xs mx-0.5">+</span>
|
||||
)}
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded shadow-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 text-center pt-4 mt-4 border-t">
|
||||
提示:按 <kbd className="px-1.5 py-0.5 text-xs font-semibold bg-gray-100 border border-gray-300 rounded">?</kbd> 键可随时打开此面板
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardShortcutsPanel;
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
.nodePanel {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.categoryTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.nodeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nodeItem:hover {
|
||||
background: #f9fafb;
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nodeItem:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nodeInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodeName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.nodeDesc {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
padding: 12px;
|
||||
background: #f1f5f9;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { NODE_DEFINITIONS } from '../nodes';
|
||||
import { NodeCategory } from '../nodes/types';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
import styles from './NodePanel.module.css';
|
||||
|
||||
interface NodePanelProps {
|
||||
className?: string;
|
||||
@ -31,59 +32,26 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
|
||||
<div
|
||||
key={nodeDefinition.nodeCode}
|
||||
draggable
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
marginBottom: '6px'
|
||||
}}
|
||||
className={styles.nodeItem}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = nodeDefinition.renderConfig.theme.primary;
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.currentTarget.style.cursor = 'grabbing';
|
||||
handleDragStart(e, nodeDefinition);
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
e.currentTarget.style.cursor = 'grab';
|
||||
}}
|
||||
onDragStart={(e) => handleDragStart(e, nodeDefinition)}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeIcon}
|
||||
style={{ color: nodeDefinition.renderConfig.theme.primary }}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
width: '20px',
|
||||
textAlign: 'center',
|
||||
color: nodeDefinition.renderConfig.theme.primary
|
||||
}}>
|
||||
{nodeDefinition.renderConfig.icon.content}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
<div className={styles.nodeInfo}>
|
||||
<div className={styles.nodeName}>
|
||||
{nodeDefinition.nodeName}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#6b7280',
|
||||
lineHeight: '1.2',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<div className={styles.nodeDesc}>
|
||||
{nodeDefinition.description}
|
||||
</div>
|
||||
</div>
|
||||
@ -99,52 +67,16 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`node-panel ${className}`} style={{
|
||||
width: '260px',
|
||||
height: '100%',
|
||||
background: '#f8fafc',
|
||||
borderRight: '1px solid #e5e7eb',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
节点面板
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
拖拽节点到画布创建工作流
|
||||
</p>
|
||||
<div className={`${styles.nodePanel} ${className}`}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>节点面板</h3>
|
||||
<p className={styles.subtitle}>拖拽节点到画布创建工作流</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: '1',
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
<div className={styles.content}>
|
||||
{Object.entries(nodesByCategory).map(([category, nodes]) => (
|
||||
<div key={category} style={{ marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
padding: '4px 0',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div key={category} className={styles.category}>
|
||||
<div className={styles.categoryTitle}>
|
||||
{categoryTitles[category as NodeCategory]}
|
||||
</div>
|
||||
{nodes.map(renderNodeItem)}
|
||||
@ -153,18 +85,12 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
|
||||
</div>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f1f5f9',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
<div className={styles.tips}>
|
||||
💡 提示:
|
||||
<br />• 拖拽节点到画布创建
|
||||
<br />• 双击节点进行配置
|
||||
<br />• 连接节点创建流程
|
||||
<br />• 按 ? 查看快捷键
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { message } from 'antd';
|
||||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import WorkflowToolbar from './components/WorkflowToolbar';
|
||||
import NodePanel from './components/NodePanel';
|
||||
@ -9,6 +10,7 @@ import FlowCanvas from './components/FlowCanvas';
|
||||
import NodeConfigModal from './components/NodeConfigModal';
|
||||
import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal';
|
||||
import FormPreviewModal from './components/FormPreviewModal';
|
||||
import KeyboardShortcutsPanel from './components/KeyboardShortcutsPanel';
|
||||
import type { FlowNode, FlowEdge, FlowNodeData } from './types';
|
||||
import type { WorkflowNodeDefinition } from './nodes/types';
|
||||
import { isConfigurableNode } from './nodes/types';
|
||||
@ -40,7 +42,10 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
|
||||
const [workflowTitle, setWorkflowTitle] = useState('新建工作流');
|
||||
const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例
|
||||
const [loading, setLoading] = useState(false); // 加载状态
|
||||
const [lastSaveTime, setLastSaveTime] = useState<Date | null>(null); // 最后保存时间
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 当前工作流ID
|
||||
const currentWorkflowId = id ? parseInt(id) : undefined;
|
||||
@ -57,6 +62,9 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
const [formDefinition, setFormDefinition] = useState<FormDefinitionResponse | null>(null);
|
||||
const [formPreviewVisible, setFormPreviewVisible] = useState(false);
|
||||
|
||||
// 快捷键面板状态
|
||||
const [shortcutsPanelOpen, setShortcutsPanelOpen] = useState(false);
|
||||
|
||||
// 提取表单字段(用于变量引用)
|
||||
const formFields = useMemo(() => {
|
||||
if (!formDefinition?.schema?.fields) return [];
|
||||
@ -109,6 +117,8 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (currentWorkflowId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await loadWorkflow(currentWorkflowId);
|
||||
if (data) {
|
||||
setNodes(data.nodes);
|
||||
@ -128,6 +138,9 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
setFormDefinition(null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -161,7 +174,7 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
const initialEdges: FlowEdge[] = [];
|
||||
|
||||
// 工具栏事件处理
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSave = useCallback(async (isAutoSave = false) => {
|
||||
const nodes = getNodes() as FlowNode[];
|
||||
const edges = getEdges() as FlowEdge[];
|
||||
|
||||
@ -175,10 +188,35 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
});
|
||||
|
||||
if (success) {
|
||||
setLastSaveTime(new Date());
|
||||
if (!isAutoSave) {
|
||||
console.log('保存工作流成功:', { nodes, edges });
|
||||
}
|
||||
}
|
||||
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
|
||||
|
||||
// 自动保存
|
||||
useEffect(() => {
|
||||
if (!currentWorkflowId || !hasUnsavedChanges) return;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
// 30秒后自动保存
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
handleSave(true);
|
||||
message.success('已自动保存', 1);
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [currentWorkflowId, hasUnsavedChanges, handleSave]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1); // 返回上一页,避免硬编码路径
|
||||
}, [navigate]);
|
||||
@ -543,11 +581,21 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
handleDelete();
|
||||
}
|
||||
}
|
||||
// ? - 显示快捷键面板
|
||||
else if (e.key === '?' && !shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
setShortcutsPanelOpen(true);
|
||||
}
|
||||
// Ctrl+S / Cmd+S - 保存
|
||||
else if (ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]);
|
||||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete, handleSave]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -557,10 +605,20 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">加载工作流中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏 */}
|
||||
<WorkflowToolbar
|
||||
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
|
||||
onSave={handleSave}
|
||||
onSave={() => handleSave(false)}
|
||||
onBack={handleBack}
|
||||
onPreviewForm={handlePreviewForm}
|
||||
hasFormDefinition={!!formDefinition}
|
||||
@ -624,6 +682,19 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
onClose={() => setFormPreviewVisible(false)}
|
||||
formDefinition={formDefinition}
|
||||
/>
|
||||
|
||||
{/* 快捷键面板 */}
|
||||
<KeyboardShortcutsPanel
|
||||
open={shortcutsPanelOpen}
|
||||
onClose={() => setShortcutsPanelOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 最后保存时间提示 */}
|
||||
{lastSaveTime && (
|
||||
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600 z-10">
|
||||
最后保存: {lastSaveTime.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user