Files
udmin/frontend/src/layouts/MainLayout.tsx
ayou 8c06849254 feat(调度任务): 实现调度任务管理功能
新增调度任务模块,支持任务的增删改查、启停及手动执行
- 后端添加 schedule_job 模型、服务、路由及调度器工具类
- 前端新增调度任务管理页面
- 修改 flow 相关接口将 id 类型从 String 改为 i64
- 添加 tokio-cron-scheduler 依赖实现定时任务调度
- 初始化时加载已启用任务并注册到调度器
2025-09-24 00:21:30 +08:00

655 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Layout, Menu, theme, Avatar, Dropdown, Space, Modal, Form, Input, message, Breadcrumb, ConfigProvider, Tabs } from 'antd'
import { useNavigate, useLocation, useOutlet } from 'react-router-dom'
import { HomeOutlined, LogoutOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, UserOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
import { clearToken, getUser as getUserLocal, setUser as setUserLocal } from '../utils/token'
import api from '../utils/axios'
import './layout.css'
import { PermissionProvider } from '../utils/permission'
import { APP_CONFIG } from '../utils/config'
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 }
import zhCN from 'antd/locale/zh_CN'
import enUS from 'antd/locale/en_US'
export default function MainLayout() {
const navigate = useNavigate()
const loc = useLocation()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
// 简易语言切换(存储到 localStorage
const [lang, setLang] = useState<'zh' | 'en'>(() => (localStorage.getItem('lang') === 'en' ? 'en' : 'zh'))
const localeObj = lang === 'zh' ? zhCN : enUS
const [user, setUser] = useState(() => getUserLocal())
const [profileOpen, setProfileOpen] = useState(false)
const [form] = Form.useForm()
const [rawMenus, setRawMenus] = useState([] as MenuItemResp[])
const [menuItems, setMenuItems] = useState([] as any[])
// 从菜单提取权限编码集合(后端通过菜单返回 perms包含页面/按钮权限)
const permissionCodes = useMemo(() => {
const set = new Set<string>()
rawMenus.forEach((m: MenuItemResp) => {
const p = m.perms
if (p && typeof p === 'string') {
p.split(',').map(s => s.trim()).filter(Boolean).forEach(code => set.add(code))
}
})
return set
}, [rawMenus])
const iconMap: Record<string, any> = useMemo(() => ({
HomeOutlined: <HomeOutlined />,
UserOutlined: <UserOutlined />,
TeamOutlined: <TeamOutlined />,
SettingOutlined: <SettingOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
KeyOutlined: <KeyOutlined />,
DashboardOutlined: <DashboardOutlined />,
FileOutlined: <FileOutlined />,
LockOutlined: <LockOutlined />,
MenuOutlined: <MenuOutlined />,
PieChartOutlined: <PieChartOutlined />,
BarChartOutlined: <BarChartOutlined />,
TableOutlined: <TableOutlined />,
CalendarOutlined: <CalendarOutlined />,
FormOutlined: <FormOutlined />,
SearchOutlined: <SearchOutlined />,
ToolOutlined: <ToolOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
ShopOutlined: <ShopOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
FolderOutlined: <FolderOutlined />,
CloudOutlined: <CloudOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
ApiOutlined: <ApiOutlined />,
CodeOutlined: <CodeOutlined />,
BugOutlined: <BugOutlined />,
BellOutlined: <BellOutlined />,
// 新增常用图标映射
PlusOutlined: <PlusOutlined />,
EditOutlined: <EditOutlined />,
DeleteOutlined: <DeleteOutlined />,
UploadOutlined: <UploadOutlined />,
DownloadOutlined: <DownloadOutlined />,
EyeOutlined: <EyeOutlined />,
EyeInvisibleOutlined: <EyeInvisibleOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
LikeOutlined: <LikeOutlined />,
DislikeOutlined: <DislikeOutlined />,
SmileOutlined: <SmileOutlined />,
FrownOutlined: <FrownOutlined />,
PhoneOutlined: <PhoneOutlined />,
MailOutlined: <MailOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
GlobalOutlined: <GlobalOutlined />,
AimOutlined: <AimOutlined />,
CompassOutlined: <CompassOutlined />,
CameraOutlined: <CameraOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
SoundOutlined: <SoundOutlined />,
WifiOutlined: <WifiOutlined />,
RocketOutlined: <RocketOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
ExperimentOutlined: <ExperimentOutlined />,
BulbOutlined: <BulbOutlined />,
GiftOutlined: <GiftOutlined />,
BankOutlined: <BankOutlined />,
WalletOutlined: <WalletOutlined />,
MoneyCollectOutlined: <MoneyCollectOutlined />,
BookOutlined: <BookOutlined />,
ReadOutlined: <ReadOutlined />,
ProfileOutlined: <ProfileOutlined />,
CloudUploadOutlined: <CloudUploadOutlined />,
CloudDownloadOutlined: <CloudDownloadOutlined />,
InboxOutlined: <InboxOutlined />,
FolderAddOutlined: <FolderAddOutlined />,
SlidersOutlined: <SlidersOutlined />,
FilterOutlined: <FilterOutlined />,
AlertOutlined: <AlertOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
FieldTimeOutlined: <FieldTimeOutlined />,
HistoryOutlined: <HistoryOutlined />,
ContactsOutlined: <ContactsOutlined />,
SolutionOutlined: <SolutionOutlined />,
IdcardOutlined: <IdcardOutlined />,
QrcodeOutlined: <QrcodeOutlined />,
ScanOutlined: <ScanOutlined />,
SafetyOutlined: <SafetyOutlined />,
SecurityScanOutlined: <SecurityScanOutlined />,
UnlockOutlined: <UnlockOutlined />,
HddOutlined: <HddOutlined />,
CopyOutlined: <CopyOutlined />,
ScissorOutlined: <ScissorOutlined />,
SnippetsOutlined: <SnippetsOutlined />,
FileProtectOutlined: <FileProtectOutlined />,
DesktopOutlined: <DesktopOutlined />,
LaptopOutlined: <LaptopOutlined />,
MobileOutlined: <MobileOutlined />,
TabletOutlined: <TabletOutlined />,
ClusterOutlined: <ClusterOutlined />,
AppstoreAddOutlined: <AppstoreAddOutlined />,
PlusSquareOutlined: <PlusSquareOutlined />,
SyncOutlined: <SyncOutlined />,
ReloadOutlined: <ReloadOutlined />,
}), [])
useEffect(() => {
const fetchMenus = async () => {
try {
const { data } = await api.get('/auth/menus')
if (data?.code === 0) {
const list: MenuItemResp[] = data.data || []
setRawMenus(list)
}
} catch (e) {
// ignore
}
}
fetchMenus()
}, [])
useEffect(() => {
// 前端移除不再需要的“权限”相关菜单项,并仅展示可见菜单项
const filtered = rawMenus
.filter((m: MenuItemResp) => m.path !== '/permissions')
.filter((m: MenuItemResp) => m.visible === true)
// 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))
filtered
.filter((m: MenuItemResp) => m.type !== 3) // skip buttons
.forEach((m: MenuItemResp) => {
const key = m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`
const item: any = {
key,
label: m.name,
icon: m.icon ? iconMap[m.icon] : undefined,
// NOTE: 不要预先放 children: [],否则在 antd Menu 中会被当成可展开项
}
map.set(m.id, item)
})
const roots: any[] = []
filtered
.filter((m: MenuItemResp) => m.type !== 3)
.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 = []
parent.children.push(node)
} else {
roots.push(node)
}
})
// 完全依赖后端返回的菜单树(不再追加本地兜底项)
setMenuItems(roots)
}, [rawMenus, iconMap])
const handleLogout = async () => {
try {
await api.post('/auth/logout')
} catch (_) {}
clearToken()
navigate('/login', { replace: true })
}
const openProfile = () => {
const u = getUserLocal()
setUser(u)
form.setFieldsValue({ username: u?.username, nickname: u?.nickname, avatar: (u as any)?.avatar || '' })
setProfileOpen(true)
}
const handleSaveProfile = async () => {
try{
const values = await form.validateFields()
const uid = (user as any)?.id
if (uid) {
const { data } = await api.put(`/users/${uid}`, { nickname: values.nickname })
if (data?.code !== 0) throw new Error(data?.message || '更新失败')
}
const merged = { ...(user as any), nickname: values.nickname, avatar: values.avatar }
setUserLocal(merged)
setUser(merged)
message.success('资料已更新')
setProfileOpen(false)
}catch(e: any){ if(e?.errorFields) return; message.error(e?.message || '更新失败') }
}
const onMenuClick = (info: any) => {
const key = info?.key as string
if (key && key.startsWith('/')) navigate(key)
}
const selectedKeys = useMemo(() => {
const k = loc.pathname
if (k.startsWith('/flows/')) return ['/flows']
return [k]
}, [loc.pathname])
// 根据当前路径计算需要展开的父级菜单 keys不包含叶子自身
const [openKeys, setOpenKeys] = useState<string[]>(() => {
try {
const v = localStorage.getItem('layout:menuOpenKeys')
const arr = v ? JSON.parse(v) : []
return Array.isArray(arr) ? (arr as string[]) : []
} catch { return [] }
})
// 持久化用户手动展开/收起的子菜单状态,刷新后保持
useEffect(() => {
try { localStorage.setItem('layout:menuOpenKeys', JSON.stringify(openKeys)) } catch {}
}, [openKeys])
// 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换)
const currentAncestors = useMemo(() => {
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) })
const currentPath = loc.pathname
let foundId: number | undefined = pathToId.get(currentPath)
if (!foundId) {
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as string[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
const ancestors = chain.slice(0, Math.max(0, chain.length - 1))
return ancestors.map((m: MenuItemResp) => (m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`))
}, [rawMenus, loc.pathname])
useEffect(() => {
setOpenKeys(prev => {
const s = new Set(prev)
currentAncestors.forEach(k => s.add(k))
return Array.from(s)
})
}, [currentAncestors])
const dropdownMenuItems = useMemo(() => ([
{ key: 'profile', label: '个人信息', icon: <UserOutlined /> },
{ type: 'divider' as any },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined /> },
]), [])
// 基于菜单树与当前路径计算面包屑
const breadcrumbItems = useMemo(() => {
// 过滤掉不需要显示的菜单与按钮type 3
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) })
const currentPath = loc.pathname
let foundId: number | undefined
if (pathToId.has(currentPath)) {
foundId = pathToId.get(currentPath)!
} else {
// 兜底:取路径前缀最长匹配(例如 /users/detail/123 匹配 /users
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as any[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
return chain.map((m: MenuItemResp, idx: number) => ({
title: (m.path && idx !== chain.length - 1)
? <a onClick={(e: any) => { e.preventDefault(); navigate(m.path!) }}>{m.name}</a>
: m.name
}))
}, [rawMenus, loc.pathname, navigate])
// 路由页签与缓存(放在 return 之前的顶层)
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刷新不丢失
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(() => {
try {
const v = localStorage.getItem('layout:leftCollapsed')
return v ? v === '1' : false
} catch { return false }
})
// 断点状态:仅用于小屏时临时强制折叠,不写入持久化
const [isBroken, setIsBroken] = useState(false)
useEffect(() => {
try { localStorage.setItem('layout:leftCollapsed', leftCollapsed ? '1' : '0') } catch {}
}, [leftCollapsed])
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// path -> menu 映射,便于取标题与 keep_alive
const pathToMenu = useMemo(() => {
const map = new Map<string, MenuItemResp>()
rawMenus.forEach((m: MenuItemResp) => { if (m.path) map.set(m.path, m) })
return map
}, [rawMenus])
// 用于标题兜底:当精确路径无菜单时,使用“最长前缀匹配”的菜单(例如 /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
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
changed = true
return { ...t, title: nextTitle, keepAlive: nextKeep }
}
return t
}
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 }
}
return t
})
return changed ? next : prev
})
}, [pathToMenu, menusForLookup])
// 根据当前路由补齐 tabskey 含 querytitle 从“精确或前缀匹配”的菜单名获取),并为 /flows/editor 覆盖标题为“流程设计”
useEffect(() => {
const curPath = loc.pathname
if (!curPath.startsWith('/')) return
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 === key) ? prev : [...prev, { key, title, keepAlive }]))
}, [loc.pathname, loc.search, pathToMenu, menusForLookup])
// 确保缓存当前激活页(若 keepAlive 打开)
useEffect(() => {
const cur = tabs.find(t => t.key === activeKey)
if (!cur) return
if (cur.keepAlive && outlet && !cache[activeKey]) {
setCache(prev => ({ ...prev, [activeKey]: outlet }))
}
}, [activeKey, outlet, tabs, cache])
const onTabChange = (key: string) => { if (key && key !== activeKey) navigate(key) }
const onTabEdit = (targetKey: any, action: 'add' | 'remove') => {
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: '/' }
if (fallback.key) navigate(fallback.key)
}
return next
})
}
}
// 监听流程编辑器的脏状态事件 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]))
}, [])
return (
<PermissionProvider codes={permissionCodes}>
<ConfigProvider locale={localeObj}>
<Layout style={{ minHeight: '100vh' }}>
{/* 其余内容保持不变 */}
<Sider
collapsible
collapsed={isBroken ? true : leftCollapsed}
onCollapse={(collapsed, type) => {
// 仅在用户点击触发器时更新用户偏好;响应式折叠不写入
if (type === 'clickTrigger') {
setLeftCollapsed(collapsed)
}
}}
breakpoint="lg"
onBreakpoint={(broken) => setIsBroken(broken)}
trigger={null}
collapsedWidth={56}
width={220}
style={{ background: colorBgContainer }}
>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: leftCollapsed ? '16px' : '18px',
cursor: 'pointer',
userSelect: 'none'
}}
onClick={() => setLeftCollapsed(c => !c)}
title={leftCollapsed ? '展开菜单' : '收起菜单'}
>
{leftCollapsed ? APP_CONFIG.SITE_NAME_SHORT : APP_CONFIG.SITE_NAME}
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
inlineCollapsed={isBroken ? true : leftCollapsed}
items={menuItems}
onClick={onMenuClick}
style={{ borderInlineEnd: 0 }}
/>
</Sider>
<Layout>
<Header style={{ padding: '8px 16px', background: colorBgContainer }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
{/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */}
{tabs.length > 0 && (
<Tabs
className="top-tabs"
hideAdd
type="editable-card"
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}
/>
)}
</div>
<Space size={16} align="center">
<Dropdown
menu={{
items: [
{ key: 'zh', label: 'CN' },
{ key: 'en', label: 'EN' },
],
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"
>
<Space style={{ cursor: 'pointer', userSelect: 'none' }}>
<GlobalOutlined />
<span>{lang === 'zh' ? 'CN' : 'EN'}</span>
</Space>
</Dropdown>
<Dropdown
menu={{ items: dropdownMenuItems as any, onClick: (e: { key: string }) => { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }}
placement="bottomRight"
>
<Space style={{ cursor: 'pointer' }}>
<Avatar size={32} icon={<UserOutlined />} />
<span>{(user as any)?.nickname || (user as any)?.username || '用户'}</span>
</Space>
</Dropdown>
</Space>
</div>
</Header>
<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>
<Modal title="个人信息" open={profileOpen} onOk={handleSaveProfile} onCancel={() => setProfileOpen(false)} okText="保存" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名">
<Input disabled />
</Form.Item>
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="avatar" label="头像 URL">
<Input placeholder="请输入头像图片地址URL" />
</Form.Item>
</Form>
</Modal>
</ConfigProvider>
</PermissionProvider>
)
}
// 说明:菜单完全依赖后端返回的路径,若需要本地添加“调度任务管理”菜单,请在后端创建菜单项 path: '/schedule-jobs',前端会自动展示。