| 1 | /** |
| 2 | * 分享对话查看页面 - Shared Chat View |
| 3 | * 功能:通过分享 token 查看对话记录(只读模式) |
| 4 | * 路由:/chat?token=xxx |
| 5 | */ |
| 6 | 'use client' |
| 7 | |
| 8 | import type { TaskDetail } from '@/api/ai/ai.types' |
| 9 | import { ArrowLeft, Eye, Link2Off } from 'lucide-react' |
| 10 | import { useRouter, useSearchParams } from 'next/navigation' |
| 11 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 12 | import { agentApi } from '@/api/ai/ai.api' |
| 13 | import { useTransClient } from '@/app/i18n/client' |
| 14 | import { ChatMessage } from '@/components/Chat/ChatMessage' |
| 15 | import { Button } from '@/components/ui/button' |
| 16 | import { Skeleton } from '@/components/ui/skeleton' |
| 17 | import { useDocumentTitle } from '@/hooks' |
| 18 | import { convertMessages } from './[taskId]/utils' |
| 19 | |
| 20 | /** 分享页面加载骨架屏 */ |
| 21 | function SharedChatSkeleton() { |
| 22 | return ( |
| 23 | <div className="flex flex-col h-full"> |
| 24 | {/* Header skeleton */} |
| 25 | <div className="flex items-center gap-3 px-4 py-3"> |
| 26 | <Skeleton className="w-8 h-8 rounded-md" /> |
| 27 | <Skeleton className="h-5 w-40" /> |
| 28 | </div> |
| 29 | {/* Messages skeleton */} |
| 30 | <div className="flex-1 px-4 py-4"> |
| 31 | <div className="max-w-6xl mx-auto space-y-4"> |
| 32 | {Array.from({ length: 4 }).map((_, i) => ( |
| 33 | <div key={i} className="flex gap-3"> |
| 34 | <Skeleton className="w-10 h-10 rounded-full shrink-0" /> |
| 35 | <div className="flex-1 space-y-2"> |
| 36 | <Skeleton className="h-4 w-32" /> |
| 37 | <Skeleton className="h-16 w-full rounded-lg" /> |
| 38 | </div> |
| 39 | </div> |
| 40 | ))} |
| 41 | </div> |
| 42 | </div> |
| 43 | </div> |
| 44 | ) |
| 45 | } |
| 46 | |
| 47 | /** 分享链接无效/过期提示 */ |
| 48 | function SharedChatError({ type, onBack }: { type: 'notFound' | 'expired', onBack: () => void }) { |
| 49 | const { t } = useTransClient('share') |
| 50 | |
| 51 | return ( |
| 52 | <div className="flex flex-col h-full"> |
| 53 | {/* Header */} |
| 54 | <header className="flex items-center gap-3 px-4 py-3 shrink-0"> |
| 55 | <Button variant="ghost" size="icon" onClick={onBack} className="w-8 h-8 cursor-pointer"> |
| 56 | <ArrowLeft className="w-5 h-5" /> |
| 57 | </Button> |
| 58 | <h1 className="text-base font-medium text-foreground">{t('sharedConversation')}</h1> |
| 59 | </header> |
| 60 | |
| 61 | {/* Error content */} |
| 62 | <div className="flex-1 flex items-center justify-center p-8"> |
| 63 | <div className="flex flex-col items-center gap-4 text-center max-w-sm"> |
| 64 | <div className="w-16 h-16 rounded-full bg-muted-foreground/10 flex items-center justify-center"> |
| 65 | <Link2Off className="w-8 h-8 text-muted-foreground" /> |
| 66 | </div> |
| 67 | <h2 className="text-lg font-medium text-foreground"> |
| 68 | {type === 'expired' ? t('linkExpired') : t('linkNotFound')} |
| 69 | </h2> |
| 70 | <p className="text-sm text-muted-foreground"> |
| 71 | {type === 'expired' |
| 72 | ? t('linkExpiredDesc') |
| 73 | : t('linkNotFoundDesc')} |
| 74 | </p> |
| 75 | <Button onClick={onBack} className="mt-2 cursor-pointer"> |
| 76 | {t('backToHome')} |
| 77 | </Button> |
| 78 | </div> |
| 79 | </div> |
| 80 | </div> |
| 81 | ) |
| 82 | } |
| 83 | |
| 84 | export default function SharedChatPage() { |
| 85 | const { t } = useTransClient('share') |
| 86 | const router = useRouter() |
| 87 | const searchParams = useSearchParams() |
| 88 | const token = searchParams.get('token') |
| 89 | |
| 90 | // 状态 |
| 91 | const [loading, setLoading] = useState(true) |
| 92 | const [error, setError] = useState<'notFound' | 'expired' | null>(null) |
| 93 | const [taskDetail, setTaskDetail] = useState<TaskDetail | null>(null) |
| 94 | |
| 95 | // 滚动控制 |
| 96 | const containerRef = useRef<HTMLDivElement>(null) |
| 97 | const bottomRef = useRef<HTMLDivElement>(null) |
| 98 | |
| 99 | // 动态更新页面标题 |
| 100 | useDocumentTitle(taskDetail?.title, t('sharedConversation')) |
| 101 | |
| 102 | // 加载分享数据 |
| 103 | useEffect(() => { |
| 104 | if (!token) { |
| 105 | setError('notFound') |
| 106 | setLoading(false) |
| 107 | return |
| 108 | } |
| 109 | |
| 110 | const loadSharedTask = async () => { |
| 111 | try { |
| 112 | setLoading(true) |
| 113 | setError(null) |
| 114 | |
| 115 | let data: TaskDetail | null = null |
| 116 | |
| 117 | // Debug 模式:从本地文件加载数据 |
| 118 | if (token === 'debug') { |
| 119 | const res = await fetch('/en/agent测试res查询发布详情数据.txt') |
| 120 | const json = await res.json() |
| 121 | data = json.data |
| 122 | } |
| 123 | else { |
| 124 | // 正常模式:调用 API |
| 125 | const res = await agentApi.getTaskByShareToken(token) |
| 126 | data = res?.data ?? null |
| 127 | } |
| 128 | |
| 129 | if (data) { |
| 130 | setTaskDetail(data) |
| 131 | } |
| 132 | else { |
| 133 | setError('notFound') |
| 134 | } |
| 135 | } |
| 136 | catch (err: any) { |
| 137 | console.error('Failed to load shared task:', err) |
| 138 | // 根据错误类型判断是过期还是不存在 |
| 139 | if (err?.response?.status === 410 || (typeof err?.message === 'string' && err.message.includes('expired'))) { |
| 140 | setError('expired') |
| 141 | } |
| 142 | else { |
| 143 | setError('notFound') |
| 144 | } |
| 145 | } |
| 146 | finally { |
| 147 | setLoading(false) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | loadSharedTask() |
| 152 | }, [token]) |
| 153 | |
| 154 | // 返回首页 |
| 155 | const handleBack = useCallback(() => { |
| 156 | router.push('/') |
| 157 | }, [router]) |
| 158 | |
| 159 | // 转换消息格式 |
| 160 | const displayMessages = taskDetail?.messages ? convertMessages(taskDetail.messages) : [] |
| 161 | |
| 162 | // 过滤出用户和 AI 消息 |
| 163 | const filteredMessages = displayMessages.filter( |
| 164 | message => message.role === 'user' || message.role === 'assistant', |
| 165 | ) |
| 166 | |
| 167 | // 加载中 |
| 168 | if (loading) { |
| 169 | return <SharedChatSkeleton /> |
| 170 | } |
| 171 | |
| 172 | // 错误状态 |
| 173 | if (error) { |
| 174 | return <SharedChatError type={error} onBack={handleBack} /> |
| 175 | } |
| 176 | |
| 177 | return ( |
| 178 | <div className="flex flex-col h-full"> |
| 179 | {/* 顶部导航 - 只读模式 */} |
| 180 | <header className="flex items-center gap-3 px-4 py-3 shrink-0"> |
| 181 | {/* 返回按钮 */} |
| 182 | <Button variant="ghost" size="icon" onClick={handleBack} className="w-8 h-8 cursor-pointer"> |
| 183 | <ArrowLeft className="w-5 h-5" /> |
| 184 | </Button> |
| 185 | |
| 186 | {/* 标题 */} |
| 187 | <h1 className="text-base font-medium text-foreground line-clamp-1 flex-1"> |
| 188 | {taskDetail?.title || t('sharedConversation')} |
| 189 | </h1> |
| 190 | |
| 191 | {/* 只读标识 */} |
| 192 | <div className="flex items-center gap-1.5 px-2 py-1 rounded bg-muted/50 text-muted-foreground"> |
| 193 | <Eye className="w-3.5 h-3.5" /> |
| 194 | <span className="text-xs">{t('viewOnly')}</span> |
| 195 | </div> |
| 196 | </header> |
| 197 | |
| 198 | {/* 消息列表 - 只读 */} |
| 199 | <div className="flex-1 relative overflow-hidden"> |
| 200 | <div ref={containerRef} className="h-full overflow-y-auto"> |
| 201 | <div className="max-w-6xl mx-auto px-4 pt-4 pb-4 flex gap-4 flex-col"> |
| 202 | {filteredMessages.map(message => ( |
| 203 | <ChatMessage |
| 204 | key={message.id} |
| 205 | role={message.role as 'user' | 'assistant'} |
| 206 | content={message.content} |
| 207 | medias={message.medias} |
| 208 | status={message.status} |
| 209 | errorMessage={message.errorMessage} |
| 210 | createdAt={message.createdAt} |
| 211 | steps={message.steps} |
| 212 | // 只读模式:不显示 actions 和 publishFlows |
| 213 | actions={[]} |
| 214 | publishFlows={[]} |
| 215 | isGenerating={false} |
| 216 | /> |
| 217 | ))} |
| 218 | |
| 219 | {/* 底部占位元素 */} |
| 220 | <div ref={bottomRef} /> |
| 221 | </div> |
| 222 | </div> |
| 223 | </div> |
| 224 | |
| 225 | {/* 底部提示 - 只读模式无输入框 */} |
| 226 | <div className="p-4 shrink-0"> |
| 227 | <div className="max-w-6xl mx-auto flex items-center justify-center gap-2 text-sm text-muted-foreground"> |
| 228 | <Eye className="w-4 h-4" /> |
| 229 | <span> |
| 230 | {t('viewOnly')} |
| 231 | {' '} |
| 232 | - |
| 233 | {t('sharedConversation')} |
| 234 | </span> |
| 235 | </div> |
| 236 | </div> |
| 237 | </div> |
| 238 | ) |
| 239 | } |
| 240 |