返回 AiToEarn
index.tsx
1 /**
2 * SettingsModal - 设置弹框组件
3 * 包含个人资料、Agent、通用设置等功能
4 * 采用可扩展的 Tab 配置结构
5 * 支持移动端响应式布局
6 */
7
8 'use client'
9
10 import type { ReactNode } from 'react'
11 import type { SettingsTab } from '@/store/settingsModal'
12 import { Globe, User } from 'lucide-react'
13 import Image from 'next/image'
14 import { useEffect, useRef, useState } from 'react'
15 import { useTransClient } from '@/app/i18n/client'
16 import logo from '@/assets/images/logo.png'
17 import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
18 import { useUserStore } from '@/store/user'
19 import { cn } from '@/utils/className'
20 import {
21 GeneralTab,
22 ProfileTab,
23 } from './tabs'
24
25 /** Tab 配置项类型 */
26 interface TabConfig {
27 key: SettingsTab
28 icon: ReactNode
29 label: string
30 /** 是否需要登录才能显示 */
31 requireAuth?: boolean
32 }
33
34 /** 设置页面类型(导出供外部使用) */
35 export type { SettingsTab } from '@/store/settingsModal'
36
37 export interface SettingsModalProps {
38 /** 是否显示弹框 */
39 open: boolean
40 /** 关闭弹框回调 */
41 onClose: () => void
42 /** 默认选中的 Tab */
43 defaultTab?: SettingsTab
44 }
45
46 /**
47 * SettingsModal 设置弹框组件
48 */
49 export function SettingsModal({ open, onClose, defaultTab }: SettingsModalProps) {
50 // 预加载所有子组件需要的 namespace,避免切换 tab 时闪烁
51 const { t } = useTransClient(['settings', 'profile'])
52 const token = useUserStore(state => state.token)
53 const isLoggedIn = !!token
54 const [activeTab, setActiveTab] = useState<SettingsTab>(isLoggedIn ? 'profile' : 'general')
55 const tabContainerRef = useRef<HTMLDivElement>(null)
56
57 // 打开弹框时,如果有 defaultTab 则使用它
58 useEffect(() => {
59 if (open && defaultTab && isLoggedIn) {
60 setActiveTab(defaultTab)
61 }
62 }, [open, defaultTab, isLoggedIn])
63
64 // 登录状态变化时重置标签
65 useEffect(() => {
66 if (!isLoggedIn && activeTab === 'profile') {
67 setActiveTab('general')
68 }
69 }, [isLoggedIn, activeTab])
70
71 // 选中 Tab 自动居中(仅移动端水平滚动时生效)
72 useEffect(() => {
73 if (!open)
74 return
75
76 const scrollToCenter = () => {
77 const container = tabContainerRef.current
78 if (!container)
79 return
80
81 // 容器不可水平滚动时跳过(桌面端垂直布局)
82 if (container.scrollWidth <= container.clientWidth)
83 return
84
85 const activeElement = container.querySelector(
86 `[data-tab-key="${activeTab}"]`,
87 ) as HTMLElement
88 if (!activeElement)
89 return
90
91 const containerWidth = container.clientWidth
92 const tabCenter = activeElement.offsetLeft + activeElement.offsetWidth / 2
93 const targetScroll = tabCenter - containerWidth / 2
94
95 const maxScroll = container.scrollWidth - containerWidth
96 const finalScroll = Math.max(0, Math.min(targetScroll, maxScroll))
97
98 container.scrollTo({ left: finalScroll, behavior: 'smooth' })
99 }
100
101 // 延迟执行,等待 Dialog 入场动画完成后 DOM 尺寸才正确
102 const raf = requestAnimationFrame(() => {
103 scrollToCenter()
104 })
105
106 return () => cancelAnimationFrame(raf)
107 }, [activeTab, open])
108
109 // Tab 配置列表(易于扩展)
110 const tabConfigs: TabConfig[] = [
111 {
112 key: 'profile',
113 icon: <User className="h-4 w-4" />,
114 label: t('tabs.profile'),
115 requireAuth: true,
116 },
117 { key: 'general', icon: <Globe className="h-4 w-4" />, label: t('tabs.general') },
118 ]
119
120 // 根据登录状态和 placeId 过滤 Tab
121 const visibleTabs = tabConfigs.filter((tab) => {
122 if (tab.requireAuth && !isLoggedIn)
123 return false
124 return true
125 })
126
127 // 渲染右侧内容
128 const renderContent = () => {
129 switch (activeTab) {
130 case 'profile':
131 return isLoggedIn ? <ProfileTab onClose={onClose} /> : <GeneralTab />
132 case 'general':
133 return <GeneralTab />
134 default:
135 return null
136 }
137 }
138
139 return (
140 <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
141 <DialogContent
142 className="w-[95vw] max-w-[900px] gap-0 overflow-hidden p-0 sm:p-0 md:w-[900px]"
143 aria-describedby={undefined}
144 >
145 <DialogTitle className="sr-only">{t('title')}</DialogTitle>
146
147 {/* 移动端布局:垂直排列;桌面端:水平排列 */}
148 <div className="flex h-[85vh] max-h-[650px] flex-col overflow-hidden md:h-[70vh] md:max-h-none md:flex-row">
149 {/* 侧边栏/顶部导航 */}
150 <div className="flex w-full shrink-0 overflow-hidden flex-col border-b border-border md:w-52 md:border-b-0 md:border-r">
151 {/* 侧边栏头部 - Logo + 项目名称 */}
152 <div className="flex h-14 shrink-0 items-center gap-2 px-4 md:h-auto md:px-5 md:py-4">
153 <Image src={logo} alt="Aitoearn" width={24} height={24} className="hidden md:block md:h-7 md:w-7" />
154 <span className="text-sm font-semibold tracking-tight text-foreground md:text-base">
155 <span className="md:hidden">{t('title')}</span>
156 <span className="hidden md:inline">Aitoearn</span>
157 </span>
158 </div>
159
160 {/* Tab 列表 - 移动端水平滚动,桌面端垂直列表 */}
161 <div ref={tabContainerRef} className="relative flex gap-1 overflow-x-auto scrollbar-none snap-x snap-mandatory px-3 pb-3 md:flex-col md:snap-none md:overflow-x-visible md:px-3 md:pb-4">
162 {visibleTabs.map((tab) => {
163 const isActive = activeTab === tab.key
164
165 return (
166 <button
167 key={tab.key}
168 data-tab-key={tab.key}
169 onClick={() => setActiveTab(tab.key)}
170 className={cn(
171 'snap-center flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-left text-sm transition-all md:gap-2 md:py-2.5',
172 isActive
173 ? 'bg-muted font-medium text-foreground'
174 : 'text-muted-foreground hover:bg-muted hover:text-foreground',
175 )}
176 >
177 <span className="relative flex items-center justify-center">{tab.icon}</span>
178 <span className="whitespace-nowrap">{tab.label}</span>
179 </button>
180 )
181 })}
182 </div>
183 </div>
184
185 {/* 右侧/下方内容区域 */}
186 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
187 {/* 右侧头部 - 设置标题 */}
188 <div className="hidden shrink-0 items-center border-b border-border md:flex md:px-6 md:py-4">
189 <h2 className="text-base font-semibold text-foreground md:text-lg">{t('title')}</h2>
190 </div>
191
192 {/* 右侧内容 - 确保内容区域可以正确滚动 */}
193 <div className="flex-1 overflow-auto px-4 py-4 md:px-6 md:py-6">
194 <div className="flex min-h-full min-w-0 flex-col">{renderContent()}</div>
195 </div>
196 </div>
197 </div>
198 </DialogContent>
199 </Dialog>
200 )
201 }
202
203 export default SettingsModal
204
204 lines Plain Text