返回 AiToEarn
index.tsx
1 /**
2 * ChatHeader - 对话页面顶部导航栏
3 * 显示返回按钮、标题、生成状态
4 */
5 'use client'
6
7 import { ArrowLeft, Bot, Heart, Loader2, Pencil, Share2, Star, X } from 'lucide-react'
8 import React, { useEffect, useRef, useState } from 'react'
9 import { useTransClient } from '@/app/i18n/client'
10 import RatingModal from '@/components/Chat/Rating'
11 import ShareModal from '@/components/Chat/Share/ShareModal'
12 import { Button } from '@/components/ui/button'
13 import { cn } from '@/utils/className'
14 import styles from '../../../layout.module.css'
15
16 const STORAGE_KEY = 'chat-announcement-dismissed'
17
18 /** PC 端公告提示 */
19 function DesktopAnnouncementTip() {
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
27 useEffect(() => {
28 try {
29 setDismissed(sessionStorage.getItem(STORAGE_KEY) === 'true')
30 }
31 catch {
32 setDismissed(false)
33 }
34 }, [])
35
36 useEffect(() => {
37 if (dismissed)
38 return
39
40 const checkScroll = () => {
41 if (containerRef.current && textRef.current) {
42 setNeedsScroll(textRef.current.scrollWidth > containerRef.current.offsetWidth)
43 }
44 }
45
46 checkScroll()
47 window.addEventListener('resize', checkScroll)
48 return () => window.removeEventListener('resize', checkScroll)
49 }, [text, dismissed])
50
51 const handleDismiss = () => {
52 setDismissed(true)
53 try {
54 sessionStorage.setItem(STORAGE_KEY, 'true')
55 }
56 catch {
57 // ignore
58 }
59 }
60
61 if (dismissed)
62 return null
63
64 return (
65 <div className="hidden md:flex items-center gap-1.5 px-2 py-1 rounded bg-muted/50 max-w-md">
66 <Bot className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
67 <div ref={containerRef} className="flex-1 overflow-hidden flex items-center">
68 {needsScroll ? (
69 <div className={styles.marquee}>
70 <span ref={textRef} className="text-xs text-muted-foreground whitespace-nowrap pr-6">
71 {text}
72 </span>
73 <span className="text-xs text-muted-foreground whitespace-nowrap pr-6">{text}</span>
74 </div>
75 ) : (
76 <span ref={textRef} className="text-xs text-muted-foreground whitespace-nowrap">
77 {text}
78 </span>
79 )}
80 </div>
81 <button
82 type="button"
83 onClick={handleDismiss}
84 className="p-0.5 rounded hover:bg-muted text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer shrink-0"
85 aria-label="关闭"
86 >
87 <X className="w-3 h-3" />
88 </button>
89 </div>
90 )
91 }
92
93 export interface IChatHeaderProps {
94 /** 任务标题 */
95 title?: string
96 /** 默认标题(任务标题为空时显示) */
97 defaultTitle: string
98 /** 是否正在生成 */
99 isGenerating: boolean
100 /** 生成进度(0-100) */
101 progress: number
102 /** 思考中文案 */
103 thinkingText: string
104 /** 任务ID(用于评分) */
105 taskId?: string
106 /** 任务评分(用于显示实星) */
107 rating?: number | null
108 /** 是否已收藏 */
109 isFavorited?: boolean
110 /** 收藏加载中 */
111 isFavoriteLoading?: boolean
112 /** 收藏切换回调 */
113 onFavoriteToggle?: () => void | Promise<void>
114 /** 编辑标题回调 */
115 onEditTitle?: () => void
116 /** 返回按钮点击 */
117 onBack: () => void
118 }
119
120 export function ChatHeader({
121 title,
122 defaultTitle,
123 isGenerating,
124 progress,
125 thinkingText,
126 onBack,
127 taskId,
128 rating,
129 isFavorited = false,
130 isFavoriteLoading = false,
131 onFavoriteToggle,
132 onEditTitle,
133 }: IChatHeaderProps) {
134 const { t } = useTransClient('chat')
135 const [ratingOpen, setRatingOpen] = useState(false)
136 const [shareOpen, setShareOpen] = useState(false)
137 return (
138 <header className="flex items-center gap-3 px-4 py-3 shrink-0" id="chatHeader">
139 {/* 返回按钮 */}
140 <Button variant="ghost" size="icon" onClick={onBack} className="w-8 h-8">
141 <ArrowLeft className="w-5 h-5" />
142 </Button>
143
144 {/* 标题 + 编辑 icon */}
145 <button
146 onClick={onEditTitle}
147 className="flex items-center gap-1.5 group cursor-pointer hover:opacity-80 transition-opacity"
148 aria-label={t('task.editTitle')}
149 >
150 <h1 className="text-base font-medium text-foreground line-clamp-1">
151 {title || defaultTitle}
152 </h1>
153 <Pencil className="w-4 h-4 text-muted-foreground shrink-0 opacity-60 group-hover:opacity-100 transition-opacity" />
154 </button>
155
156 {/* PC 端公告提示 */}
157 <DesktopAnnouncementTip />
158
159 {/* 右侧区域:生成状态 + 收藏 + 分享 + 评分 */}
160 <div className="ml-auto flex items-center gap-2">
161 <div className="flex items-center gap-2.5">
162 {/* 收藏按钮 */}
163 <Button
164 variant="ghost"
165 onClick={onFavoriteToggle}
166 disabled={isFavoriteLoading}
167 className="ml-1 text-sm text-muted-foreground flex items-center gap-1 h-8 px-2 cursor-pointer"
168 aria-label={isFavorited ? t('task.unfavorite') : t('task.favorite')}
169 >
170 {isFavoriteLoading ? (
171 <Loader2 className="w-5 h-5 animate-spin" />
172 ) : (
173 <Heart
174 className={cn(
175 'w-5 h-5 transition-colors',
176 isFavorited && 'text-red-500 fill-red-500',
177 )}
178 />
179 )}
180 <span>{isFavorited ? t('task.unfavorite') : t('task.favorite')}</span>
181 </Button>
182 <Button
183 variant="ghost"
184 onClick={() => setShareOpen(true)}
185 className="ml-1 text-sm text-muted-foreground flex items-center gap-1 h-8 px-2 cursor-pointer"
186 >
187 <Share2 className="w-5 h-5" />
188 <span>{t('task.share')}</span>
189 </Button>
190 {/* 文本提示,移动端隐藏以节省空间 */}
191 <Button
192 variant="ghost"
193 onClick={() => setRatingOpen(true)}
194 className="ml-1 text-sm text-muted-foreground flex items-center gap-1 h-8 px-2 cursor-pointer"
195 aria-label={t('task.rate')}
196 >
197 <Star
198 className={`w-5 h-5 ${rating ? 'text-amber-400' : 'text-muted-foreground'}`}
199 {...(rating ? { fill: 'currentColor' } : {})}
200 />
201 <span>{t('task.rate') || '评分'}</span>
202 </Button>
203 </div>
204 </div>
205 <ShareModal taskId={taskId ?? ''} open={shareOpen} onOpenChange={v => setShareOpen(v)} />
206 <RatingModal
207 taskId={taskId ?? ''}
208 open={ratingOpen}
209 onClose={() => setRatingOpen(false)}
210 onSaved={() => {
211 setRatingOpen(false)
212 }}
213 />
214 </header>
215 )
216 }
217
217 lines Plain Text