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:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

View File

@ -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
// 根据当前路由补齐 tabskey 含 querytitle 从“精确或前缀匹配”的菜单名获取),并为 /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>