| 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 |