新增调度任务模块,支持任务的增删改查、启停及手动执行 - 后端添加 schedule_job 模型、服务、路由及调度器工具类 - 前端新增调度任务管理页面 - 修改 flow 相关接口将 id 类型从 String 改为 i64 - 添加 tokio-cron-scheduler 依赖实现定时任务调度 - 初始化时加载已启用任务并注册到调度器
655 lines
28 KiB
TypeScript
655 lines
28 KiB
TypeScript
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])
|
||
|
||
// 根据当前路由补齐 tabs(key 含 query,title 从“精确或前缀匹配”的菜单名获取),并为 /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',前端会自动展示。
|