| 1 | /** |
| 2 | * UserDropdownMenu - 用户头像下拉菜单组件 |
| 3 | * 参考飞书风格,将用户相关功能收纳到下拉菜单中 |
| 4 | * 支持展开/折叠两种状态 |
| 5 | */ |
| 6 | |
| 7 | 'use client' |
| 8 | |
| 9 | import type { SidebarCommonProps } from '../types' |
| 10 | import type { SettingsTab } from '@/store/settingsModal' |
| 11 | import { |
| 12 | BookOpen, |
| 13 | ChevronRight, |
| 14 | FileText, |
| 15 | ScrollText, |
| 16 | Settings, |
| 17 | Shield, |
| 18 | } from 'lucide-react' |
| 19 | import Link from 'next/link' |
| 20 | import { useState } from 'react' |
| 21 | import { useTransClient } from '@/app/i18n/client' |
| 22 | import { DOCS_URL, GITHUB_REPO } from '@/app/layout/shared/constants' |
| 23 | import { useGitHubStars } from '@/app/layout/shared/hooks/useGitHubStars' |
| 24 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
| 25 | import { Button } from '@/components/ui/button' |
| 26 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
| 27 | import { Skeleton } from '@/components/ui/skeleton' |
| 28 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' |
| 29 | import { useUserStore } from '@/store/user' |
| 30 | import { navigateToLogin } from '@/utils/auth' |
| 31 | import { cn } from '@/utils/className' |
| 32 | import { getOssUrl } from '@/utils/oss' |
| 33 | |
| 34 | /** GitHub SVG 图标 */ |
| 35 | function GitHubIcon({ className }: { className?: string }) { |
| 36 | return ( |
| 37 | <svg className={cn('h-4 w-4', className)} viewBox="0 0 24 24" fill="currentColor"> |
| 38 | <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> |
| 39 | </svg> |
| 40 | ) |
| 41 | } |
| 42 | |
| 43 | export interface UserDropdownMenuProps extends SidebarCommonProps { |
| 44 | /** 打开设置弹框 */ |
| 45 | onOpenSettings: (defaultTab?: SettingsTab) => void |
| 46 | } |
| 47 | |
| 48 | /** 菜单项组件 */ |
| 49 | function MenuItem({ |
| 50 | icon: Icon, |
| 51 | label, |
| 52 | rightContent, |
| 53 | onClick, |
| 54 | href, |
| 55 | external, |
| 56 | className, |
| 57 | }: { |
| 58 | icon: React.ElementType |
| 59 | label: string |
| 60 | rightContent?: React.ReactNode |
| 61 | onClick?: () => void |
| 62 | href?: string |
| 63 | external?: boolean |
| 64 | className?: string |
| 65 | }) { |
| 66 | const content = ( |
| 67 | <> |
| 68 | <Icon size={16} className="shrink-0" /> |
| 69 | <span className="flex-1 text-left">{label}</span> |
| 70 | {rightContent} |
| 71 | </> |
| 72 | ) |
| 73 | |
| 74 | const baseClassName = cn( |
| 75 | 'flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent', |
| 76 | className, |
| 77 | ) |
| 78 | |
| 79 | if (href) { |
| 80 | if (external) { |
| 81 | return ( |
| 82 | <a href={href} target="_blank" rel="noopener noreferrer" className={baseClassName}> |
| 83 | {content} |
| 84 | </a> |
| 85 | ) |
| 86 | } |
| 87 | return ( |
| 88 | <Link href={href} className={baseClassName}> |
| 89 | {content} |
| 90 | </Link> |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | return ( |
| 95 | <button onClick={onClick} className={cn(baseClassName, 'border-none bg-transparent')}> |
| 96 | {content} |
| 97 | </button> |
| 98 | ) |
| 99 | } |
| 100 | |
| 101 | /** 已登录用户的下拉菜单内容 */ |
| 102 | function LoggedInMenuContent({ |
| 103 | onOpenSettings, |
| 104 | onClose, |
| 105 | }: { |
| 106 | onOpenSettings: (defaultTab?: SettingsTab) => void |
| 107 | onClose: () => void |
| 108 | }) { |
| 109 | const { t } = useTransClient(['common']) |
| 110 | const userInfo = useUserStore(state => state.userInfo) |
| 111 | const starCount = useGitHubStars() |
| 112 | |
| 113 | const handleOpenSettings = () => { |
| 114 | onOpenSettings() |
| 115 | onClose() |
| 116 | } |
| 117 | |
| 118 | return ( |
| 119 | <> |
| 120 | <div className="flex flex-col gap-1 p-2"> |
| 121 | {/* 用户信息区域 */} |
| 122 | <div className="flex items-center gap-3 px-3 py-2"> |
| 123 | <Avatar className="h-10 w-10 shrink-0 border border-border"> |
| 124 | <AvatarImage |
| 125 | src={getOssUrl(userInfo?.avatar) || ''} |
| 126 | alt={userInfo?.name || t('common:unknownUser')} |
| 127 | /> |
| 128 | <AvatarFallback className="bg-muted-foreground font-semibold text-background"> |
| 129 | {userInfo?.name?.charAt(0)?.toUpperCase() || 'U'} |
| 130 | </AvatarFallback> |
| 131 | </Avatar> |
| 132 | <div className="flex min-w-0 flex-1 flex-col"> |
| 133 | <span |
| 134 | className="truncate text-sm font-medium text-foreground" |
| 135 | data-testid="sidebar-user-name" |
| 136 | > |
| 137 | {userInfo?.name || t('common:unknownUser')} |
| 138 | </span> |
| 139 | </div> |
| 140 | </div> |
| 141 | |
| 142 | <div className="my-1 h-px bg-border" /> |
| 143 | |
| 144 | <MenuItem |
| 145 | icon={GitHubIcon} |
| 146 | label="GitHub" |
| 147 | rightContent={<span className="text-xs text-muted-foreground">{starCount}</span>} |
| 148 | href={`https://github.com/${GITHUB_REPO}`} |
| 149 | external |
| 150 | /> |
| 151 | |
| 152 | <div className="my-1 h-px bg-border" /> |
| 153 | |
| 154 | {/* 中频:文档 */} |
| 155 | <div className="group/docs relative"> |
| 156 | {/* 触发行 */} |
| 157 | <div className="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"> |
| 158 | <BookOpen size={16} className="shrink-0" /> |
| 159 | <span className="flex-1 text-left">{t('common:documents')}</span> |
| 160 | <ChevronRight size={14} className="text-muted-foreground" /> |
| 161 | </div> |
| 162 | {/* 右侧飞出面板 */} |
| 163 | <div className="invisible absolute left-full top-0 z-50 pl-1 opacity-0 transition-all group-hover/docs:visible group-hover/docs:opacity-100"> |
| 164 | <div className="w-48 rounded-md border bg-popover p-1 shadow-md"> |
| 165 | <MenuItem icon={FileText} label={t('common:helpDocs')} href={DOCS_URL} external /> |
| 166 | <MenuItem |
| 167 | icon={FileText} |
| 168 | label={t('common:pluginGuide')} |
| 169 | href="/websit/plugin-guide" |
| 170 | /> |
| 171 | <MenuItem |
| 172 | icon={Shield} |
| 173 | label={t('common:privacyPolicy')} |
| 174 | href="/websit/privacy-policy" |
| 175 | /> |
| 176 | <MenuItem |
| 177 | icon={ScrollText} |
| 178 | label={t('common:termsOfService')} |
| 179 | href="/websit/terms-of-service" |
| 180 | /> |
| 181 | </div> |
| 182 | </div> |
| 183 | </div> |
| 184 | <div className="my-1 h-px bg-border" /> |
| 185 | |
| 186 | {/* 高频:设置 */} |
| 187 | <div data-testid="sidebar-settings-entry"> |
| 188 | <MenuItem icon={Settings} label={t('common:settings')} onClick={handleOpenSettings} /> |
| 189 | </div> |
| 190 | </div> |
| 191 | </> |
| 192 | ) |
| 193 | } |
| 194 | |
| 195 | export function UserDropdownMenu({ collapsed, onOpenSettings }: UserDropdownMenuProps) { |
| 196 | const token = useUserStore(state => state.token) |
| 197 | const userInfo = useUserStore(state => state.userInfo) |
| 198 | const hasHydrated = useUserStore(state => state._hasHydrated) |
| 199 | const { t } = useTransClient('common') |
| 200 | const [open, setOpen] = useState(false) |
| 201 | |
| 202 | // 如果还未 hydrate 完成,显示骨架屏 |
| 203 | if (!hasHydrated) { |
| 204 | if (collapsed) { |
| 205 | return <Skeleton className="h-9 w-9 rounded-md" /> |
| 206 | } |
| 207 | return <Skeleton className="mt-2 h-9 w-full rounded-md" /> |
| 208 | } |
| 209 | |
| 210 | // 未登录状态显示登录按钮 |
| 211 | if (!token) { |
| 212 | const handleLogin = () => navigateToLogin() |
| 213 | |
| 214 | if (collapsed) { |
| 215 | return ( |
| 216 | <TooltipProvider> |
| 217 | <Tooltip> |
| 218 | <TooltipTrigger asChild> |
| 219 | <Button |
| 220 | onClick={handleLogin} |
| 221 | size="icon" |
| 222 | className="h-9 w-9" |
| 223 | data-testid="sidebar-login-btn" |
| 224 | > |
| 225 | <span className="text-sm font-semibold">In</span> |
| 226 | </Button> |
| 227 | </TooltipTrigger> |
| 228 | <TooltipContent side="right"> |
| 229 | <p>{t('login')}</p> |
| 230 | </TooltipContent> |
| 231 | </Tooltip> |
| 232 | </TooltipProvider> |
| 233 | ) |
| 234 | } |
| 235 | |
| 236 | return ( |
| 237 | <Button onClick={handleLogin} className="mt-2 w-full" data-testid="sidebar-login-btn"> |
| 238 | {t('login')} |
| 239 | </Button> |
| 240 | ) |
| 241 | } |
| 242 | |
| 243 | // 已登录状态显示下拉菜单 |
| 244 | return ( |
| 245 | <div |
| 246 | className={cn( |
| 247 | 'flex items-center rounded-lg transition-colors hover:bg-accent', |
| 248 | collapsed ? 'p-1' : 'gap-2 px-2 py-1.5', |
| 249 | )} |
| 250 | onMouseEnter={() => setOpen(true)} |
| 251 | onMouseLeave={() => setOpen(false)} |
| 252 | > |
| 253 | <Popover open={open} onOpenChange={setOpen}> |
| 254 | <TooltipProvider> |
| 255 | <Tooltip> |
| 256 | <PopoverTrigger asChild> |
| 257 | <TooltipTrigger asChild> |
| 258 | {/* 头像区域 - 点击打开设置弹框 */} |
| 259 | <button |
| 260 | onClick={(e) => { |
| 261 | e.preventDefault() |
| 262 | e.stopPropagation() |
| 263 | onOpenSettings('profile') |
| 264 | }} |
| 265 | data-testid="sidebar-user-trigger" |
| 266 | className={cn( |
| 267 | 'relative flex cursor-pointer items-center border-none bg-transparent flex-1', |
| 268 | collapsed ? 'justify-center' : 'gap-2', |
| 269 | )} |
| 270 | > |
| 271 | <Avatar className="h-8 w-8 shrink-0 border border-border"> |
| 272 | <AvatarImage |
| 273 | src={getOssUrl(userInfo?.avatar) || ''} |
| 274 | alt={userInfo?.name || t('unknownUser')} |
| 275 | /> |
| 276 | <AvatarFallback className="bg-muted-foreground font-semibold text-background"> |
| 277 | {userInfo?.name?.charAt(0)?.toUpperCase() || 'U'} |
| 278 | </AvatarFallback> |
| 279 | </Avatar> |
| 280 | |
| 281 | {!collapsed && ( |
| 282 | <div className="flex min-w-0 flex-1 flex-col items-start"> |
| 283 | <span className="w-full truncate text-left text-sm font-medium text-foreground"> |
| 284 | {userInfo?.name || t('unknownUser')} |
| 285 | </span> |
| 286 | </div> |
| 287 | )} |
| 288 | </button> |
| 289 | </TooltipTrigger> |
| 290 | </PopoverTrigger> |
| 291 | {collapsed && !open && ( |
| 292 | <TooltipContent side="right"> |
| 293 | <p>{t('profile')}</p> |
| 294 | </TooltipContent> |
| 295 | )} |
| 296 | </Tooltip> |
| 297 | </TooltipProvider> |
| 298 | |
| 299 | <PopoverContent |
| 300 | side={collapsed ? 'right' : 'top'} |
| 301 | align={collapsed ? 'end' : 'start'} |
| 302 | className="w-64 p-0" |
| 303 | sideOffset={4} |
| 304 | data-testid="sidebar-user-menu" |
| 305 | onMouseEnter={() => setOpen(true)} |
| 306 | onMouseLeave={() => setOpen(false)} |
| 307 | > |
| 308 | <LoggedInMenuContent onOpenSettings={onOpenSettings} onClose={() => setOpen(false)} /> |
| 309 | </PopoverContent> |
| 310 | </Popover> |
| 311 | </div> |
| 312 | ) |
| 313 | } |
| 314 |