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