feat(flows): 新增流程编辑器基础功能与相关组件
feat(backend): 添加流程模型与服务支持 feat(frontend): 实现流程编辑器UI与交互 feat(assets): 添加流程节点图标资源 feat(plugins): 实现上下文菜单和运行时插件 feat(components): 新增基础节点和侧边栏组件 feat(routes): 添加流程相关路由配置 feat(models): 创建流程和运行日志数据模型 feat(services): 实现流程服务层逻辑 feat(migration): 添加流程相关数据库迁移 feat(config): 更新前端配置支持流程编辑器 feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
@ -7,7 +7,7 @@ import api from '../utils/axios'
|
||||
import './layout.css'
|
||||
import { PermissionProvider } from '../utils/permission'
|
||||
import { APP_CONFIG } from '../utils/config'
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Header, Sider, Content, Footer } = Layout
|
||||
|
||||
interface MenuItemResp { id: number; parent_id?: number | null; name: string; path?: string | null; component?: string | null; type: number; icon?: string | null; order_no: number; visible: boolean; status: number; perms?: string | null; keep_alive?: boolean }
|
||||
|
||||
@ -156,10 +156,12 @@ export default function MainLayout() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 前端移除不再需要的“权限”相关菜单项
|
||||
const filtered = rawMenus.filter((m: MenuItemResp) => m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
// 前端移除不再需要的“权限”相关菜单项,并仅展示可见菜单项
|
||||
const filtered = rawMenus
|
||||
.filter((m: MenuItemResp) => m.path !== '/permissions')
|
||||
.filter((m: MenuItemResp) => m.visible === true)
|
||||
|
||||
// build tree items from filtered
|
||||
// build tree items from filtered(仅包含可见且非按钮项)
|
||||
const map = new Map<number, any>()
|
||||
const byId = new Map<number, MenuItemResp>()
|
||||
filtered.forEach((m: MenuItemResp) => byId.set(m.id, m))
|
||||
@ -181,7 +183,7 @@ export default function MainLayout() {
|
||||
.forEach((m: MenuItemResp) => {
|
||||
const node = map.get(m.id)
|
||||
const pid = m.parent_id || undefined
|
||||
// 只有当父级不是按钮时,才允许挂载,避免将节点放到按钮下
|
||||
// 只有当父级存在且不是按钮时,才允许挂载;若父级不可见或不存在则提升为根
|
||||
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
|
||||
const parent = map.get(pid)
|
||||
if (!parent.children) parent.children = []
|
||||
@ -190,6 +192,7 @@ export default function MainLayout() {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
// 完全依赖后端返回的菜单树(不再追加本地兜底项)
|
||||
setMenuItems(roots)
|
||||
}, [rawMenus, iconMap])
|
||||
|
||||
@ -231,6 +234,7 @@ export default function MainLayout() {
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const k = loc.pathname
|
||||
if (k.startsWith('/flows/')) return ['/flows']
|
||||
return [k]
|
||||
}, [loc.pathname])
|
||||
|
||||
@ -248,7 +252,7 @@ export default function MainLayout() {
|
||||
}, [openKeys])
|
||||
// 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换)
|
||||
const currentAncestors = useMemo(() => {
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions')
|
||||
const idMap = new Map<number, MenuItemResp>()
|
||||
const pathToId = new Map<string, number>()
|
||||
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
|
||||
@ -293,7 +297,7 @@ export default function MainLayout() {
|
||||
// 基于菜单树与当前路径计算面包屑
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
// 过滤掉不需要显示的菜单与按钮(type 3)
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions')
|
||||
const idMap = new Map<number, MenuItemResp>()
|
||||
const pathToId = new Map<string, number>()
|
||||
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
|
||||
@ -333,6 +337,9 @@ export default function MainLayout() {
|
||||
type TabItem = { key: string; title: string; keepAlive: boolean }
|
||||
const [tabs, setTabs] = useState<TabItem[]>([])
|
||||
const [cache, setCache] = useState<Record<string, React.ReactNode>>({})
|
||||
// 记录每个标签页是否有未保存更改(脏标记)
|
||||
const dirtyRef = useRef<Record<string, boolean>>({})
|
||||
const [dirtyVersion, setDirtyVersion] = useState(0) // 仅用于触发重渲染(展示脏标记)
|
||||
const outlet = useOutlet()
|
||||
// 右侧菜单折叠状态(默认收起)
|
||||
// 左侧菜单折叠状态:持久化到 localStorage,刷新不丢失
|
||||
@ -359,15 +366,29 @@ export default function MainLayout() {
|
||||
return map
|
||||
}, [rawMenus])
|
||||
|
||||
// 当前路由激活 key
|
||||
const activeKey = useMemo(() => loc.pathname, [loc.pathname])
|
||||
// 用于标题兜底:当精确路径无菜单时,使用“最长前缀匹配”的菜单(例如 /flows/editor 命中 /flows)
|
||||
const menusForLookup = useMemo(() => {
|
||||
return rawMenus
|
||||
.filter((m: MenuItemResp) => m.type !== 3 && m.path && m.path.startsWith('/'))
|
||||
.sort((a: MenuItemResp, b: MenuItemResp) => (b.path!.length - a.path!.length))
|
||||
}, [rawMenus])
|
||||
const resolveMenuByPath = (pathname: string): MenuItemResp | undefined => {
|
||||
const exact = pathToMenu.get(pathname)
|
||||
if (exact) return exact
|
||||
for (const m of menusForLookup) {
|
||||
if (pathname.startsWith(m.path!)) return m
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 当前路由激活 key(包含查询参数,支持同一路由多开不同 query 的标签页)
|
||||
const activeKey = useMemo(() => (loc.pathname + (loc.search || '')), [loc.pathname, loc.search])
|
||||
|
||||
// 根据菜单变更,修正已存在 tabs 的标题与 keepAlive(解决刷新后标题变为路径的问题)
|
||||
useEffect(() => {
|
||||
setTabs(prev => {
|
||||
let changed = false
|
||||
const next = prev.map(t => {
|
||||
// 特殊处理首页:无菜单映射时也应显示“首页”
|
||||
if (t.key === '/') {
|
||||
const nextTitle = '首页'
|
||||
const nextKeep = true
|
||||
@ -377,10 +398,14 @@ export default function MainLayout() {
|
||||
}
|
||||
return t
|
||||
}
|
||||
const m = pathToMenu.get(t.key)
|
||||
if (!m) return t
|
||||
const nextKeep = (typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
|
||||
const nextTitle = m.name || t.title
|
||||
const pathname = t.key.split('?')[0]
|
||||
const m = resolveMenuByPath(pathname)
|
||||
let nextTitle = m?.name || t.title
|
||||
// 对 /flows/editor 提供更高优先级的标题覆盖:即使存在 /flows 的前缀匹配,也使用“流程设计”
|
||||
if (pathname === '/flows/editor' && m?.path !== '/flows/editor') {
|
||||
nextTitle = '流程设计'
|
||||
}
|
||||
const nextKeep = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : t.keepAlive
|
||||
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
|
||||
changed = true
|
||||
return { ...t, title: nextTitle, keepAlive: nextKeep }
|
||||
@ -389,17 +414,22 @@ export default function MainLayout() {
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [pathToMenu])
|
||||
}, [pathToMenu, menusForLookup])
|
||||
|
||||
// 根据当前路由补齐 tabs
|
||||
// 根据当前路由补齐 tabs(key 含 query,title 从“精确或前缀匹配”的菜单名获取),并为 /flows/editor 覆盖标题为“流程设计”
|
||||
useEffect(() => {
|
||||
const curPath = loc.pathname
|
||||
if (!curPath.startsWith('/')) return
|
||||
const m = pathToMenu.get(curPath)
|
||||
const title = m?.name || (curPath === '/' ? '首页' : curPath)
|
||||
const key = curPath + (loc.search || '')
|
||||
const m = resolveMenuByPath(curPath)
|
||||
let title = m?.name || (curPath === '/' ? '首页' : curPath)
|
||||
// 对 /flows/editor 提供更高优先级的标题覆盖:即使存在 /flows 的前缀匹配,也使用“流程设计”
|
||||
if (curPath === '/flows/editor' && m?.path !== '/flows/editor') {
|
||||
title = '流程设计'
|
||||
}
|
||||
const keepAlive = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
|
||||
setTabs(prev => (prev.some(t => t.key === curPath) ? prev : [...prev, { key: curPath, title, keepAlive }]))
|
||||
}, [loc.pathname, pathToMenu])
|
||||
setTabs(prev => (prev.some(t => t.key === key) ? prev : [...prev, { key, title, keepAlive }]))
|
||||
}, [loc.pathname, loc.search, pathToMenu, menusForLookup])
|
||||
|
||||
// 确保缓存当前激活页(若 keepAlive 打开)
|
||||
useEffect(() => {
|
||||
@ -415,12 +445,39 @@ export default function MainLayout() {
|
||||
if (action === 'remove') {
|
||||
const tKey = String(targetKey)
|
||||
if (tKey === '/') return
|
||||
// 如果关闭的是流程设计页,且存在未保存更改,则进行二次确认
|
||||
const isFlowsEditor = tKey.startsWith('/flows/editor')
|
||||
if (isFlowsEditor && dirtyRef.current[tKey]) {
|
||||
Modal.confirm({
|
||||
title: '未保存的更改',
|
||||
content: '当前流程设计有未保存的更改,确定要关闭吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
// 确认后继续原有关闭逻辑
|
||||
setTabs(prev => {
|
||||
const idx = prev.findIndex(t => t.key === tKey)
|
||||
if (idx === -1) return prev
|
||||
const next = prev.filter(t => t.key !== tKey)
|
||||
setCache(c => { const n = { ...c }; delete n[tKey]; return n })
|
||||
delete dirtyRef.current[tKey]
|
||||
if (tKey === activeKey) {
|
||||
const fallback = next[idx - 1] || next[idx] || { key: '/' }
|
||||
if (fallback.key) navigate(fallback.key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
setTabs(prev => {
|
||||
const idx = prev.findIndex(t => t.key === tKey)
|
||||
if (idx === -1) return prev
|
||||
const next = prev.filter(t => t.key !== tKey)
|
||||
// 清理缓存
|
||||
setCache(c => { const n = { ...c }; delete n[tKey]; return n })
|
||||
delete dirtyRef.current[tKey]
|
||||
// 如果关闭的是当前激活页,跳转到相邻页
|
||||
if (tKey === activeKey) {
|
||||
const fallback = next[idx - 1] || next[idx] || { key: '/' }
|
||||
@ -431,6 +488,34 @@ export default function MainLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听流程编辑器的脏状态事件 flows:doc-dirty
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ key: string; dirty: boolean }>).detail
|
||||
if (!detail || !detail.key) return
|
||||
// 仅记录 /flows/editor 路由的脏状态
|
||||
if (!detail.key.startsWith('/flows/editor')) return
|
||||
dirtyRef.current[detail.key] = !!detail.dirty
|
||||
setDirtyVersion(v => v + 1)
|
||||
}
|
||||
window.addEventListener('flows:doc-dirty', handler as any)
|
||||
return () => window.removeEventListener('flows:doc-dirty', handler as any)
|
||||
}, [])
|
||||
|
||||
// 在浏览器刷新/关闭时提示未保存的流程设计
|
||||
useEffect(() => {
|
||||
const beforeUnload = (e: BeforeUnloadEvent) => {
|
||||
const hasDirty = Object.entries(dirtyRef.current).some(([k, v]) => k.startsWith('/flows/editor') && v)
|
||||
if (hasDirty) {
|
||||
e.preventDefault()
|
||||
const msg = '当前页面存在未保存的更改,确定要离开吗?'
|
||||
e.returnValue = msg
|
||||
return msg
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||
}, [])
|
||||
// 确保“首页”始终存在且固定(仅初始化时补齐一次)
|
||||
useEffect(() => {
|
||||
setTabs(prev => (prev.some(t => t.key === '/') ? prev : [{ key: '/', title: '首页', keepAlive: true }, ...prev]))
|
||||
@ -470,8 +555,8 @@ export default function MainLayout() {
|
||||
}}
|
||||
onClick={() => setLeftCollapsed(c => !c)}
|
||||
title={leftCollapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{leftCollapsed ? APP_CONFIG.SITE_NAME_SHORT : APP_CONFIG.SITE_NAME}
|
||||
>
|
||||
{leftCollapsed ? APP_CONFIG.SITE_NAME_SHORT : APP_CONFIG.SITE_NAME}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
@ -494,7 +579,7 @@ export default function MainLayout() {
|
||||
className="top-tabs"
|
||||
hideAdd
|
||||
type="editable-card"
|
||||
items={tabs.map(t => ({ key: t.key, label: t.title, closable: t.key !== '/' }))}
|
||||
items={tabs.map(t => ({ key: t.key, label: (t.key.startsWith('/flows/editor') && dirtyRef.current[t.key]) ? `${t.title} *` : t.title, closable: t.key !== '/' }))}
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
onEdit={onTabEdit as any}
|
||||
@ -508,7 +593,7 @@ export default function MainLayout() {
|
||||
{ key: 'zh', label: 'CN' },
|
||||
{ key: 'en', label: 'EN' },
|
||||
],
|
||||
onClick: ({ key }) => { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) }
|
||||
onClick: ({ key }) => { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v); window.dispatchEvent(new CustomEvent('app:lang-changed', { detail: v })); }
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
@ -529,21 +614,24 @@ export default function MainLayout() {
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: '16px', padding: 16, background: colorBgContainer, borderRadius: borderRadiusLG }}>
|
||||
{/* 缓存的标签页:非激活隐藏 */}
|
||||
{tabs.filter(t => t.keepAlive).map(t => (
|
||||
<div key={t.key} style={{ display: t.key === activeKey ? 'block' : 'none' }}>
|
||||
{cache[t.key]}
|
||||
</div>
|
||||
))}
|
||||
{/* 未开启缓存:只渲染当前路由 */}
|
||||
{(() => {
|
||||
const cur = tabs.find(t => t.key === activeKey)
|
||||
if (!cur) return null
|
||||
if (cur.keepAlive) return null
|
||||
return <div key={activeKey}>{outlet}</div>
|
||||
})()}
|
||||
<Content style={{ margin: '16px', padding: 0, background: colorBgContainer, borderRadius: borderRadiusLG, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', padding: 16 }}>
|
||||
{/* 缓存的标签页:非激活隐藏(激活页若缓存为空则回退渲染 outlet) */}
|
||||
{tabs.filter(t => t.keepAlive).map(t => (
|
||||
<div key={t.key} style={{ display: t.key === activeKey ? 'block' : 'none', height: '100%' }}>
|
||||
{t.key === activeKey ? (cache[t.key] ?? outlet) : cache[t.key]}
|
||||
</div>
|
||||
))}
|
||||
{/* 未开启缓存:只渲染当前路由 */}
|
||||
{(() => {
|
||||
const cur = tabs.find(t => t.key === activeKey)
|
||||
if (!cur) return null
|
||||
if (cur.keepAlive) return null
|
||||
return <div key={activeKey} style={{ height: '100%' }}>{outlet}</div>
|
||||
})()}
|
||||
</div>
|
||||
</Content>
|
||||
<Footer style={{ textAlign: 'center' }}>{APP_CONFIG.FOOTER_TEXT.replace('{year}', String(new Date().getFullYear()))}</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user