diff --git a/DESIGN.md b/DESIGN.md index 1e5a513..cbd9914 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5,8 +5,9 @@ - Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。 - 所有人都可以浏览 Wiki 内容。 - 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。 -- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。 +- 前台以 Home 首页、Pokedex(Main Game / Event)、Habitat Dex(Main Game / Event)、Collections(Main Game / Event / Ancient Artifacts)、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。 - Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。 +- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。 - 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。 ## 技术栈 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1636be3..222c59d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,7 +18,8 @@ import { iconItem, iconLife, iconPokemon, - iconRecipe + iconRecipe, + type AppIcon } from './icons'; import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n'; import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api'; @@ -34,24 +35,66 @@ const languages = ref([ let removeAuthListener: (() => void) | null = null; let removeLocaleListener: (() => void) | null = null; -function inDevBadge() { - return { label: t('common.inDev'), tone: 'info' as const }; +type NavBadge = { + label: string; + tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; +}; + +type NavLinkItem = { + label: string; + to: string; + icon?: AppIcon; + badge?: NavBadge; +}; + +type NavGroupItem = { + key: string; + label: string; + icon?: AppIcon; + children: NavLinkItem[]; +}; + +type NavItem = NavLinkItem | NavGroupItem; + +function inDevBadge(): NavBadge { + return { label: t('common.inDev'), tone: 'info' }; } function can(permissionKey: string) { return currentUser.value?.permissions.includes(permissionKey) === true; } -const navItems = computed(() => { - const items = [ +const navItems = computed(() => { + const items: NavItem[] = [ { label: t('nav.home'), to: '/', icon: iconHome }, - { label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon }, - { label: t('nav.eventPokemon'), to: '/event-pokemon', icon: iconEvent }, - { label: t('nav.habitats'), to: '/habitats', icon: iconHabitat }, - { label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent }, - { label: t('nav.items'), to: '/items', icon: iconItem }, - { label: t('nav.eventItems'), to: '/event-items', icon: iconEvent }, - { label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact }, + { + key: 'pokedex', + label: t('nav.pokedex'), + icon: iconPokemon, + children: [ + { label: t('nav.mainGame'), to: '/pokemon', icon: iconPokemon }, + { label: t('nav.event'), to: '/event-pokemon', icon: iconEvent } + ] + }, + { + key: 'habitat-dex', + label: t('nav.habitatDex'), + icon: iconHabitat, + children: [ + { label: t('nav.mainGame'), to: '/habitats', icon: iconHabitat }, + { label: t('nav.event'), to: '/event-habitats', icon: iconEvent } + ] + }, + { + key: 'collections', + label: t('nav.collections'), + icon: iconItem, + children: [ + { label: t('nav.mainGame'), to: '/items', icon: iconItem }, + { label: t('nav.event'), to: '/event-items', icon: iconEvent }, + { label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact } + ] + }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() }, { label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index f5bafb9..a1ce2a3 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -3,24 +3,54 @@ import { Icon } from '@iconify/vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; -import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons'; +import { + iconChevronDown, + iconChevronRight, + iconClose, + iconLogin, + iconLogout, + iconMenu, + iconProfile, + iconRegister, + iconTranslate, + type AppIcon +} from '../icons'; import type { AuthUser, Language } from '../services/api'; import PokeBallMark from './PokeBallMark.vue'; import StatusBadge from './StatusBadge.vue'; +type NavBadge = { + label: string; + tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; +}; + +type NavLinkItem = { + label: string; + to: string; + icon?: AppIcon; + badge?: NavBadge; +}; + +type NavGroupItem = { + key: string; + label: string; + icon?: AppIcon; + children: NavLinkItem[]; +}; + +type NavItem = NavLinkItem | NavGroupItem; + +type SidebarTooltip = { + label: string; + top: number; + left: number; +}; + defineProps<{ currentUser: AuthUser | null; languages: Language[]; locale: string; - navItems: Array<{ - label: string; - to: string; - icon?: AppIcon; - badge?: { - label: string; - tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; - }; - }>; + navItems: NavItem[]; }>(); const emit = defineEmits<{ @@ -33,25 +63,61 @@ const route = useRoute(); const copyrightYear = new Date().getFullYear(); const languageMenu = ref(null); const languageMenuButton = ref(null); +const sideNav = ref(null); const languageMenuOpen = ref(false); const sidebarOpen = ref(false); +const sidebarCollapsed = ref(false); +const expandedNavGroups = ref>(new Set()); +const sidebarTooltip = ref(null); +const sidebarTooltipTarget = ref(null); function closeLanguageMenu() { languageMenuOpen.value = false; } +function clearSidebarTooltipTarget() { + sidebarTooltipTarget.value?.removeAttribute('aria-describedby'); +} + +function hideSidebarTooltip() { + clearSidebarTooltipTarget(); + sidebarTooltipTarget.value = null; + sidebarTooltip.value = null; +} + function closeSidebar() { sidebarOpen.value = false; closeLanguageMenu(); + hideSidebarTooltip(); } function toggleSidebar() { sidebarOpen.value = !sidebarOpen.value; closeLanguageMenu(); + hideSidebarTooltip(); +} + +function toggleSidebarCollapsed() { + sidebarCollapsed.value = !sidebarCollapsed.value; + closeLanguageMenu(); + hideSidebarTooltip(); +} + +function toggleNavGroup(key: string) { + const nextGroups = new Set(expandedNavGroups.value); + if (nextGroups.has(key)) { + nextGroups.delete(key); + } else { + nextGroups.add(key); + } + expandedNavGroups.value = nextGroups; + closeLanguageMenu(); + hideSidebarTooltip(); } function toggleLanguageMenu() { languageMenuOpen.value = !languageMenuOpen.value; + hideSidebarTooltip(); } function selectLocale(value: string) { @@ -79,26 +145,108 @@ function requestLogout() { emit('logout'); } +function isDesktopSidebar() { + return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches; +} + +function canShowSidebarTooltip(collapsedOnly = true) { + return isDesktopSidebar() && (!collapsedOnly || sidebarCollapsed.value); +} + +function setSidebarTooltip(label: string, target: HTMLElement) { + const rect = target.getBoundingClientRect(); + clearSidebarTooltipTarget(); + sidebarTooltipTarget.value = target; + target.setAttribute('aria-describedby', 'sidebar-tooltip'); + sidebarTooltip.value = { + label, + top: rect.top + rect.height / 2, + left: rect.right + 10 + }; +} + +function showSidebarTooltip(label: string, event: MouseEvent | FocusEvent, collapsedOnly = true) { + if (!canShowSidebarTooltip(collapsedOnly) || languageMenuOpen.value) { + return; + } + + const target = event.currentTarget; + if (target instanceof HTMLElement) { + setSidebarTooltip(label, target); + } +} + +function updateSidebarTooltipPosition() { + const target = sidebarTooltipTarget.value; + const currentTooltip = sidebarTooltip.value; + if (!target || !currentTooltip || !canShowSidebarTooltip()) { + hideSidebarTooltip(); + return; + } + + if (sideNav.value?.contains(target)) { + const navRect = sideNav.value.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + if (targetRect.bottom < navRect.top || targetRect.top > navRect.bottom) { + hideSidebarTooltip(); + return; + } + } + + setSidebarTooltip(currentTooltip.label, target); +} + function isNavActive(path: string) { return route.path === path || route.path.startsWith(`${path}/`); } +function isNavGroup(item: NavItem): item is NavGroupItem { + return 'children' in item; +} + +function isNavGroupActive(item: NavGroupItem) { + return item.children.some((child) => isNavActive(child.to)); +} + +function isNavGroupExpanded(item: NavGroupItem) { + return expandedNavGroups.value.has(item.key) || isNavGroupActive(item); +} + +function navItemKey(item: NavItem) { + return isNavGroup(item) ? item.key : item.to; +} + watch(sidebarOpen, (open) => { document.body.classList.toggle('lock-scroll', open); }); +watch(sidebarCollapsed, (collapsed) => { + if (!collapsed) { + hideSidebarTooltip(); + } +}); + onMounted(() => { document.addEventListener('pointerdown', onDocumentPointerDown); + window.addEventListener('resize', updateSidebarTooltipPosition); }); onBeforeUnmount(() => { document.removeEventListener('pointerdown', onDocumentPointerDown); + window.removeEventListener('resize', updateSidebarTooltipPosition); document.body.classList.remove('lock-scroll'); + hideSidebarTooltip(); });