281 lines
7.8 KiB
TypeScript
281 lines
7.8 KiB
TypeScript
import {
|
|
mdiCloseCircle,
|
|
mdiCog,
|
|
mdiFileDocumentOutline,
|
|
mdiFileTreeOutline,
|
|
mdiHome,
|
|
mdiPlusCircle,
|
|
mdiTableEdit,
|
|
} from '@mdi/js'
|
|
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { SESSION_FORCE_LOGOUT_EVENT } from '@/services/session'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useBreadcrumbStore } from '@/stores/breadcrumbs'
|
|
import { useFavoritesStore } from '@/stores/favorites'
|
|
import { useMenuStore, type LayoutMenuItem } from '@/stores/menu'
|
|
import { useMessageStore } from '@/stores/messages'
|
|
import { useSnackbarStore } from '@/stores/snackbar'
|
|
|
|
const fixedMenuItems: LayoutMenuItem[] = [
|
|
{
|
|
title: '資料維護',
|
|
navigable: false,
|
|
subItems: [
|
|
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
|
|
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
|
|
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
|
|
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
|
|
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
|
|
],
|
|
},
|
|
{ title: '登入頁', path: '/login' },
|
|
]
|
|
|
|
const menuItemsExample: LayoutMenuItem[] = [
|
|
{ title: '首頁', icon: mdiHome, path: '/' },
|
|
{
|
|
title: '設定',
|
|
icon: mdiCog,
|
|
path: '/settings',
|
|
navigable: false,
|
|
},
|
|
...fixedMenuItems,
|
|
]
|
|
|
|
function buildMergedMenuItems(items: LayoutMenuItem[]) {
|
|
const flatPaths = new Set<string>()
|
|
const collectPaths = (list: LayoutMenuItem[]) => {
|
|
for (const item of list || []) {
|
|
if (item?.path) flatPaths.add(item.path)
|
|
if (item?.subItems?.length) collectPaths(item.subItems)
|
|
}
|
|
}
|
|
|
|
collectPaths(items)
|
|
|
|
const mergeFixedItems = (list: LayoutMenuItem[]) => {
|
|
return (list || []).map((item) => {
|
|
if (!item?.subItems?.length) return item
|
|
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
|
|
return { ...item, subItems }
|
|
})
|
|
}
|
|
|
|
const filteredFixedItems = mergeFixedItems(fixedMenuItems).filter((item) => {
|
|
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
|
|
return item.subItems.length > 0
|
|
})
|
|
|
|
return [...(items || []), ...filteredFixedItems]
|
|
}
|
|
|
|
type UseAppShellOptions = {
|
|
onLogout?: () => void
|
|
}
|
|
|
|
export function useAppShell(options: UseAppShellOptions = {}) {
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const snackbar = useSnackbarStore()
|
|
const authStore = useAuthStore()
|
|
const menuStore = useMenuStore()
|
|
const breadcrumbStore = useBreadcrumbStore()
|
|
const favoritesStore = useFavoritesStore()
|
|
const messageStore = useMessageStore()
|
|
|
|
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
|
|
|
|
const mergedFavoriteItems = computed(() => {
|
|
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
|
|
const seen = new Set<string>()
|
|
return combined.filter((item) => {
|
|
const key = item.path ?? item.title
|
|
if (!key) return false
|
|
if (seen.has(key)) return false
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
})
|
|
|
|
const layoutProps = computed(() => {
|
|
const layout = route.meta.layout
|
|
if (layout === 'default') {
|
|
return {
|
|
systemTitle: '測試環境',
|
|
favoriteItems: mergedFavoriteItems.value,
|
|
menuItems: mergedMenuItems.value,
|
|
breadcrumbItems: breadcrumbStore.breadcrumbItems,
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
function handleSelect(item: LayoutMenuItem) {
|
|
if (item.path) {
|
|
router.push(item.path)
|
|
}
|
|
}
|
|
|
|
function recursiveFindTitle(path: string, items: LayoutMenuItem[]): string | null {
|
|
for (const item of items) {
|
|
if (item.path === path) return item.title
|
|
if (item.subItems?.length) {
|
|
const found = recursiveFindTitle(path, item.subItems)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function findTitle(path: string) {
|
|
const menuTitle = recursiveFindTitle(path, menuStore.menuItems)
|
|
if (menuTitle) return menuTitle
|
|
|
|
const favoriteTitle = recursiveFindTitle(path, menuStore.favoriteItems)
|
|
if (favoriteTitle) return favoriteTitle
|
|
|
|
const exampleTitle = recursiveFindTitle(path, menuItemsExample)
|
|
if (exampleTitle) return exampleTitle
|
|
|
|
if (path === '/') return '首頁'
|
|
|
|
return path
|
|
}
|
|
|
|
function findMenuItem(path: string) {
|
|
const recursiveFind = (items: LayoutMenuItem[]): LayoutMenuItem | null => {
|
|
for (const item of items) {
|
|
if (item.path === path) return item
|
|
if (item.subItems?.length) {
|
|
const found = recursiveFind(item.subItems)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
return recursiveFind(mergedMenuItems.value)
|
|
}
|
|
|
|
const currentFavoriteInfo = computed(() => {
|
|
const path = route.path
|
|
const menuItem = findMenuItem(path)
|
|
const title =
|
|
menuItem?.title ||
|
|
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
|
|
findTitle(path)
|
|
return {
|
|
title,
|
|
path,
|
|
icon: menuItem?.icon,
|
|
}
|
|
})
|
|
|
|
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
|
const isFavoriteActionDisabled = computed(
|
|
() => !currentFavoriteInfo.value?.path || route.path === '/'
|
|
)
|
|
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
|
const favoriteActionIcon = computed(() =>
|
|
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
|
|
)
|
|
|
|
function toggleFavoriteItem(item: LayoutMenuItem) {
|
|
if (!item?.path || item.path === '/') return
|
|
favoritesStore.toggle({
|
|
title: item.title || findTitle(item.path),
|
|
path: item.path,
|
|
icon: item.icon,
|
|
})
|
|
}
|
|
|
|
function toggleFavorite() {
|
|
toggleFavoriteItem(currentFavoriteInfo.value)
|
|
}
|
|
|
|
function handleRemoveFavorite(item: LayoutMenuItem) {
|
|
toggleFavoriteItem(item)
|
|
}
|
|
|
|
function goHome() {
|
|
router.push('/')
|
|
}
|
|
|
|
function updateBreadcrumbs() {
|
|
const resolvedTitle = findTitle(route.path)
|
|
const fallbackTitle =
|
|
resolvedTitle && resolvedTitle !== route.path
|
|
? resolvedTitle
|
|
: typeof route.meta?.title === 'string'
|
|
? route.meta.title
|
|
: null
|
|
|
|
breadcrumbStore.setBreadcrumbs({
|
|
path: route.path,
|
|
menuItems: mergedMenuItems.value,
|
|
favoriteItems: mergedFavoriteItems.value,
|
|
fallbackTitle,
|
|
homeLabel: '首頁',
|
|
homeIcon: mdiHome,
|
|
})
|
|
}
|
|
|
|
function handleLayoutAction(type: string) {
|
|
if (type === 'messages') {
|
|
messageStore.open()
|
|
}
|
|
}
|
|
|
|
function performLogout(feedback: { message: string; color: string }) {
|
|
authStore.logout()
|
|
options.onLogout?.()
|
|
snackbar.show(feedback)
|
|
router.replace({ name: 'login' })
|
|
}
|
|
|
|
function handleLogout() {
|
|
performLogout({ message: '登出成功', color: 'success' })
|
|
}
|
|
|
|
function handleForceLogout(event: Event) {
|
|
const message = (event as CustomEvent)?.detail?.message || '請重新登入'
|
|
performLogout({ message, color: 'warning' })
|
|
}
|
|
|
|
watch(
|
|
[
|
|
() => route.path,
|
|
() => menuStore.menuItems,
|
|
() => menuStore.favoriteItems,
|
|
() => favoritesStore.items,
|
|
],
|
|
() => updateBreadcrumbs(),
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
onMounted(() => {
|
|
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
|
})
|
|
|
|
return {
|
|
favoriteActionIcon,
|
|
favoriteActionLabel,
|
|
favoritesStore,
|
|
goHome,
|
|
handleLayoutAction,
|
|
handleLogout,
|
|
handleRemoveFavorite,
|
|
handleSelect,
|
|
isFavoriteActionDisabled,
|
|
layoutProps,
|
|
menuStore,
|
|
mergedMenuItems,
|
|
toggleFavorite,
|
|
}
|
|
}
|