返回 AiToEarn
UserDropdownMenu.tsx
根目录 / project / aitoearn-web / src / app / layout / LayoutSidebar / components / UserDropdownMenu.tsx
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
314 lines Plain Text