refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
8.18 KiB
RainbowBridge
import { useState, useEffect, useCallback } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { Icon } from '@/components/common/Icon'
import { Avatar } from '@/components/common/Avatar'
import { navItems, type NavItem } from './Sidebar'

interface MobileNavProps {
  open: boolean
  onClose: () => void
  userName?: string
  userAvatar?: string
  isAdmin?: boolean
  onSettingsClick?: () => void
  onAdminClick?: () => void
  onLogout?: () => void
}

export function MobileNav({
  open,
  onClose,
  userName = '用户',
  userAvatar,
  isAdmin = false,
  onSettingsClick,
  onAdminClick,
  onLogout,
}: MobileNavProps) {
  const location = useLocation()
  const [expandedItems, setExpandedItems] = useState<Set<String>>(() => {
    const initial = new Set<String>()
    for (const item of navItems) {
      if (item.children?.some((c) => location.pathname.startsWith(c.to))) {
        initial.add(item.to)
      }
    }
    return initial
  })

  const toggleExpand = (key: string) => {
    setExpandedItems((prev) => {
      const next = new Set(prev)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      return next
    })
  }

  // 阻止背景滚动
  useEffect(() => {
    if (open) {
      document.body.style.overflow = 'hidden'
    } else {
      document.body.style.overflow = ''
    }
    return () => { document.body.style.overflow = '' }
  }, [open])

  // ESC 关闭
  useEffect(() => {
    if (!open) return
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose()
    }
    document.addEventListener('keydown', handler)
    return () => document.removeEventListener('keydown', handler)
  }, [open, onClose])

  const handleItemClick = useCallback(() => {
    onClose()
  }, [onClose])

  if (!open) return null

  return createPortal(
    <div className="fixed inset-0 z-50 flex">
      {/* 遮罩 */}
      <div
        className="absolute inset-0 bg-black/40 animate-fade-in"
        onClick={onClose}
      />

      {/* 面板:从左侧滑入 */}
      <aside
        className={cn(
          'relative w-[280px] max-w-[80vw] h-full',
          'bg-[var(--color-surface-0)] dark:bg-[var(--color-surface-1)]',
          'shadow-2xl flex flex-col',
          'animate-slide-in-right',
        )}
      >
        {/* Header */}
        <div className="flex items-center justify-between px-4 py-4 border-b border-[var(--color-border-subtle)]">
          <div className="flex items-center gap-2.5">
            <Avatar type="user" src={userAvatar} letter={userName.charAt(0).toUpperCase()} size="sm" />
            <span className="text-sm font-medium text-[var(--color-text-primary)]">{userName}</span>
          </div>
          <button
            onClick={onClose}
            className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors"
          >
            <Icon name="close" variant="outlined" size="base" />
          </button>
        </div>

        {/* Navigation */}
        <nav className="flex-1 overflow-y-auto custom-scrollbar px-3 py-3">
          {navItems.map((item) => (
            <MobileNavItem
              key={item.to}
              item={item}
              isExpanded={expandedItems.has(item.to)}
              onToggleExpand={toggleExpand}
              onClick={handleItemClick}
            />
          ))}
        </nav>

        {/* Footer actions */}
        <div className="border-t border-[var(--color-border-subtle)] px-3 py-2 space-y-0.5">
          <button
            onClick={() => { onClose(); onSettingsClick?.() }}
            className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)] transition-colors"
          >
            <Icon name="settings" size="base" className="text-[var(--color-text-tertiary)]" />
            系统设置
          </button>
          {isAdmin && (
            <button
              onClick={() => { onClose(); onAdminClick?.() }}
              className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)] transition-colors"
            >
              <Icon name="admin_panel_settings" size="base" className="text-[var(--color-text-tertiary)]" />
              后台管理
            </button>
          )}
          <button
            onClick={() => { onClose(); onLogout?.() }}
            className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
          >
            <Icon name="logout" size="base" className="text-red-400" />
            退出登录
          </button>
        </div>
      </aside>
    </div>,
    document.body,
  )
}

// ── 手机端导航项组件 ──

function MobileNavItem({
  item,
  isExpanded,
  onToggleExpand,
  onClick,
}: {
  item: NavItem
  isExpanded: boolean
  onToggleExpand: (key: string) => void
  onClick: () => void
}) {
  const location = useLocation()
  const hasChildren = item.children && item.children.length > 0

  const isParentActive = hasChildren
    ? item.children!.some((c) => location.pathname === c.to || location.pathname.startsWith(c.to + '/'))
    : false

  if (hasChildren) {
    return (
      <div className="mb-0.5">
        <button
          onClick={() => onToggleExpand(item.to)}
          className={cn(
            'relative w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
            isParentActive
              ? 'bg-[image:var(--gradient-brand-soft)] text-[color:var(--color-brand-700)] dark:text-[color:var(--color-brand-200)]'
              : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]',
          )}
        >
          <Icon name={item.icon} size="base" />
          <span className="flex-1 text-left">{item.label}</span>
          <Icon
            name={isExpanded ? 'expand_less' : 'expand_more'}
            size="sm"
            className="text-[var(--color-text-tertiary)] flex-shrink-0"
          />
        </button>

        <div
          className={cn(
            'grid transition-all duration-200 ease-out',
            isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0 pointer-events-none',
          )}
        >
          <div className={isExpanded ? 'overflow-visible' : 'overflow-hidden'}>
            <div className="ml-9 mt-0.5 space-y-0.5 pb-0.5">
              {item.children!.map((child) => (
                <NavLink
                  key={child.to}
                  to={child.to}
                  end
                  onClick={onClick}
                  className={({ isActive }) =>
                    cn(
                      'block px-3 py-2 rounded-lg text-sm transition-colors',
                      isActive
                        ? 'text-[color:var(--color-brand-600)] dark:text-[color:var(--color-brand-400)] font-medium bg-[color:var(--color-brand-50)]/50 dark:bg-[color:var(--color-brand-900)]/20'
                        : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)]',
                    )
                  }
                >
                  {child.label}
                </NavLink>
              ))}
            </div>
          </div>
        </div>
      </div>
    )
  }

  return (
    <NavLink
      to={item.to}
      end={item.to === '/'}
      onClick={onClick}
      className={({ isActive }) =>
        cn(
          'relative flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5',
          isActive
            ? 'bg-[image:var(--gradient-brand-soft)] text-[color:var(--color-brand-700)] dark:text-[color:var(--color-brand-200)]'
            : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]',
        )
      }
    >
      <Icon name={item.icon} size="base" />
      <span>{item.label}</span>
    </NavLink>
  )
}