返回 AiToEarn
layout.tsx
根目录 / project / aitoearn-web / src / app / [lng] / chat / layout.tsx
1 /**
2 * Chat 布局组件
3 * 包含移动端公告提示
4 */
5 'use client'
6
7 import { Bot, X } from 'lucide-react'
8 import { useEffect, useRef, useState } from 'react'
9 import { useTransClient } from '@/app/i18n/client'
10 import styles from './layout.module.css'
11
12 const STORAGE_KEY = 'chat-announcement-dismissed'
13
14 /**
15 * 移动端公告提示组件
16 * 仅在移动端显示,浮动在内容区
17 * 通过获取 #chatHeader 元素来动态定位,适配手机状态栏高度
18 */
19 function MobileAnnouncementTip() {
20 const { t } = useTransClient('home')
21 const text = t('announcement.text')
22 const containerRef = useRef<HTMLDivElement>(null)
23 const textRef = useRef<HTMLSpanElement>(null)
24 const [needsScroll, setNeedsScroll] = useState(false)
25 const [dismissed, setDismissed] = useState(true)
26 const [topPosition, setTopPosition] = useState<number | null>(null)
27
28 useEffect(() => {
29 try {
30 const isDismissed = sessionStorage.getItem(STORAGE_KEY) === 'true'
31 setDismissed(isDismissed)
32 }
33 catch {
34 setDismissed(false)
35 }
36 }, [])
37
38 // 通过 chatHeader 元素计算定位
39 useEffect(() => {
40 if (dismissed)
41 return
42
43 const updatePosition = () => {
44 const chatHeader = document.getElementById('chatHeader')
45 if (chatHeader) {
46 const rect = chatHeader.getBoundingClientRect()
47 // 定位在 header 底部下方 8px
48 setTopPosition(rect.bottom + 8)
49 }
50 }
51
52 // 初始计算位置
53 updatePosition()
54
55 // 使用 MutationObserver 监听 DOM 变化,确保 chatHeader 加载后能获取到
56 const observer = new MutationObserver(() => {
57 updatePosition()
58 })
59 observer.observe(document.body, { childList: true, subtree: true })
60
61 // 监听窗口 resize 和 scroll 事件
62 window.addEventListener('resize', updatePosition)
63 window.addEventListener('scroll', updatePosition, true)
64
65 return () => {
66 observer.disconnect()
67 window.removeEventListener('resize', updatePosition)
68 window.removeEventListener('scroll', updatePosition, true)
69 }
70 }, [dismissed])
71
72 // 检测文字是否需要滚动
73 useEffect(() => {
74 if (dismissed || topPosition === null)
75 return
76
77 const checkScroll = () => {
78 if (containerRef.current && textRef.current) {
79 setNeedsScroll(textRef.current.scrollWidth > containerRef.current.offsetWidth)
80 }
81 }
82
83 // 延迟检测,确保 DOM 渲染完成
84 const timer = setTimeout(checkScroll, 100)
85 window.addEventListener('resize', checkScroll)
86 return () => {
87 clearTimeout(timer)
88 window.removeEventListener('resize', checkScroll)
89 }
90 }, [text, dismissed, topPosition])
91
92 const handleDismiss = () => {
93 setDismissed(true)
94 try {
95 sessionStorage.setItem(STORAGE_KEY, 'true')
96 }
97 catch {
98 // ignore
99 }
100 }
101
102 // 未加载位置或已关闭时不显示
103 if (dismissed || topPosition === null)
104 return null
105
106 return (
107 // 仅移动端显示,通过 chatHeader 动态定位
108 <div
109 className="md:hidden fixed left-1/2 -translate-x-1/2 z-10 w-[calc(100%-24px)] max-w-lg"
110 style={{ top: topPosition }}
111 >
112 <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-background shadow">
113 <Bot className="w-4 h-4 text-muted-foreground shrink-0" />
114 <div ref={containerRef} className="flex-1 overflow-hidden flex items-center min-h-5">
115 {needsScroll ? (
116 <div className={styles.marquee}>
117 <span ref={textRef} className="text-xs text-muted-foreground whitespace-nowrap pr-8">
118 {text}
119 </span>
120 <span className="text-xs text-muted-foreground whitespace-nowrap pr-8">{text}</span>
121 </div>
122 ) : (
123 <span ref={textRef} className="text-xs text-muted-foreground whitespace-nowrap">
124 {text}
125 </span>
126 )}
127 </div>
128 <button
129 type="button"
130 onClick={handleDismiss}
131 className="p-1 rounded hover:bg-muted text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer shrink-0"
132 aria-label="关闭"
133 >
134 <X className="w-3.5 h-3.5" />
135 </button>
136 </div>
137 </div>
138 )
139 }
140
141 export default function ChatLayout({ children }: { children: React.ReactNode }) {
142 return (
143 <div className="relative flex flex-col h-full">
144 <MobileAnnouncementTip />
145 <div className="flex-1 min-h-0">{children}</div>
146 </div>
147 )
148 }
149
149 lines Plain Text