返回 AiToEarn
1 /**
2 * PublishDetailCard - 发布详情卡片组件
3 * 在聊天消息中展示作品发布状态和详情
4 */
5
6 'use client'
7
8 import type { PublishRecordItem } from '@/api/platforms/publish.types'
9 import type { PlatType } from '@/app/config/platConfig'
10 import type { IMediaItem } from '@/store/agent'
11 import {
12 AlertCircle,
13 CheckCircle2,
14 Clock,
15 ExternalLink,
16 Eye,
17 Heart,
18 Loader2,
19 MessageCircle,
20 Share2,
21 } from 'lucide-react'
22 import Image from 'next/image'
23 import { memo, useCallback, useEffect, useMemo, useState } from 'react'
24 import { useShallow } from 'zustand/react/shallow'
25 import { PublishStatus } from '@/api/platforms/publish.constants'
26
27 import { useTransClient } from '@/app/i18n/client'
28 import AvatarPlat from '@/components/common/AvatarPlat'
29 import { OssImage } from '@/components/common/OssImage'
30 import { Button } from '@/components/ui/button'
31 import { useAccountStore } from '@/store/account'
32 import { getPlatformInfoSync } from '@/store/platformMetadata'
33 import { usePublishDetailCache } from '@/store/publishDetailCache'
34 import { cn } from '@/utils/className'
35 import { getOssUrl } from '@/utils/oss'
36 import PublishDetailSkeleton from './PublishDetailSkeleton'
37
38 // 轮询间隔(毫秒)
39 const POLLING_INTERVAL = 5000
40
41 export interface IPublishDetailCardProps {
42 /** 发布流程 ID */
43 flowId: string
44 /** 平台类型 */
45 platform?: string
46 /** 来自 result 的初始数据 */
47 initialData?: {
48 title?: string
49 description?: string
50 medias?: IMediaItem[]
51 }
52 /** 自定义类名 */
53 className?: string
54 }
55
56 /**
57 * PublishDetailCard - 发布详情卡片
58 */
59 const PublishDetailCard = memo(
60 ({ flowId, platform, initialData, className }: IPublishDetailCardProps) => {
61 const { t } = useTransClient('chat')
62 const [loading, setLoading] = useState(true)
63 const [detail, setDetail] = useState<PublishRecordItem | null>(null)
64 const [error, setError] = useState<string | null>(null)
65
66 const { fetchAndCache, getDetail } = usePublishDetailCache()
67 const { accountMap } = useAccountStore(
68 useShallow(state => ({
69 accountMap: state.accountMap,
70 })),
71 )
72
73 // 获取发布详情
74 const fetchDetail = useCallback(
75 async (forceRefresh = false) => {
76 try {
77 const data = await fetchAndCache(flowId, forceRefresh)
78 if (data) {
79 setDetail(data)
80 setError(null)
81 }
82 else {
83 setError('Failed to load')
84 }
85 }
86 catch (err) {
87 setError('Failed to load')
88 }
89 finally {
90 setLoading(false)
91 }
92 },
93 [flowId, fetchAndCache],
94 )
95
96 // 首次加载
97 useEffect(() => {
98 // 先检查缓存
99 const cached = getDetail(flowId)
100 if (cached) {
101 setDetail(cached)
102 setLoading(false)
103 }
104 else {
105 fetchDetail()
106 }
107 }, [flowId, getDetail, fetchDetail])
108
109 // 发布中状态自动轮询
110 useEffect(() => {
111 if (detail?.status === PublishStatus.PUB_LOADING) {
112 const timer = setInterval(() => {
113 fetchDetail(true) // 强制刷新
114 }, POLLING_INTERVAL)
115 return () => clearInterval(timer)
116 }
117 }, [detail?.status, fetchDetail])
118
119 // 获取平台信息
120 const platInfo = platform
121 ? getPlatformInfoSync(platform as PlatType)
122 : detail?.accountType
123 ? getPlatformInfoSync(detail.accountType)
124 : null
125
126 // 获取账户信息
127 const account = useMemo(() => {
128 return accountMap.get(detail?.accountId ?? '')
129 }, [accountMap, detail?.accountId])
130
131 // 获取封面图
132 const firstMedia = initialData?.medias?.[0]
133 const coverImage = detail?.coverUrl || detail?.imgUrlList?.[0] || firstMedia?.thumbUrl || firstMedia?.coverUrl || firstMedia?.url
134
135 // 获取描述(包含话题)
136 const description = useMemo(() => {
137 const desc = detail?.desc || initialData?.description || ''
138 const topics = detail?.topics || []
139 if (topics.length > 0) {
140 return `${desc} ${topics.map(tag => `#${tag}`).join(' ')}`
141 }
142 return desc
143 }, [detail, initialData])
144
145 // 渲染状态标签
146 const renderStatusBadge = () => {
147 if (!detail)
148 return null
149
150 switch (detail.status) {
151 case PublishStatus.RELEASED:
152 return (
153 <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-700">
154 <CheckCircle2 className="w-3 h-3" />
155 {t('publishDetail.published')}
156 </span>
157 )
158 case PublishStatus.PUB_LOADING:
159 return (
160 <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-100 text-cyan-800 border border-cyan-200 dark:bg-cyan-900 dark:text-cyan-200 dark:border-cyan-700">
161 <Loader2 className="w-3 h-3 animate-spin" />
162 {t('publishDetail.publishing')}
163 </span>
164 )
165 case PublishStatus.FAIL:
166 return (
167 <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
168 <AlertCircle className="w-3 h-3" />
169 {t('publishDetail.failed')}
170 </span>
171 )
172 case PublishStatus.UNPUBLISH:
173 return (
174 <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
175 <Clock className="w-3 h-3" />
176 {t('publishDetail.unpublished')}
177 </span>
178 )
179 default:
180 return null
181 }
182 }
183
184 // 渲染互动数据
185 const renderEngagement = () => {
186 if (!detail?.engagement)
187 return null
188
189 const { viewCount, likeCount, commentCount, shareCount } = detail.engagement
190
191 // 如果所有数据都为 0,不显示
192 if (!viewCount && !likeCount && !commentCount && !shareCount)
193 return null
194
195 return (
196 <div className="flex items-center gap-2 sm:gap-3 text-xs text-muted-foreground mt-1.5 sm:mt-2 pt-1.5 sm:pt-2 border-t border-border/50">
197 {viewCount > 0 && (
198 <span className="flex items-center gap-0.5 sm:gap-1">
199 <Eye className="w-3 h-3" />
200 {viewCount}
201 </span>
202 )}
203 {likeCount > 0 && (
204 <span className="flex items-center gap-0.5 sm:gap-1">
205 <Heart className="w-3 h-3" />
206 {likeCount}
207 </span>
208 )}
209 {commentCount > 0 && (
210 <span className="flex items-center gap-0.5 sm:gap-1">
211 <MessageCircle className="w-3 h-3" />
212 {commentCount}
213 </span>
214 )}
215 {shareCount > 0 && (
216 <span className="flex items-center gap-0.5 sm:gap-1">
217 <Share2 className="w-3 h-3" />
218 {shareCount}
219 </span>
220 )}
221 </div>
222 )
223 }
224
225 // 加载中
226 if (loading) {
227 return <PublishDetailSkeleton className={cn('w-full sm:max-w-md', className)} />
228 }
229
230 // 加载失败
231 if (error || !detail) {
232 return (
233 <div
234 className={cn(
235 'flex flex-col items-center justify-center p-2 sm:p-3 rounded-lg border border-border bg-muted/30 text-muted-foreground text-sm',
236 'w-full sm:max-w-md',
237 className,
238 )}
239 >
240 <AlertCircle className="w-5 h-5 sm:w-6 sm:h-6 mb-1 sm:mb-1.5 text-destructive/50" />
241 <span className="text-xs">{error || t('publishDetail.loading')}</span>
242 <Button
243 variant="ghost"
244 size="sm"
245 onClick={() => {
246 setLoading(true)
247 fetchDetail(true)
248 }}
249 className="mt-1 sm:mt-1.5 h-7 text-xs cursor-pointer"
250 >
251 {t('publishDetail.retry')}
252 </Button>
253 </div>
254 )
255 }
256
257 return (
258 <div
259 className={cn(
260 'flex gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border border-border bg-muted/30',
261 'w-full sm:max-w-md',
262 className,
263 )}
264 >
265 {/* 左侧:封面图 - 移动端更小 */}
266 {coverImage && (
267 <div className="relative w-14 h-14 sm:w-20 sm:h-20 rounded-md overflow-hidden bg-muted shrink-0">
268 <Image
269 src={getOssUrl(coverImage)}
270 alt={detail.title || 'Cover'}
271 fill
272 className="object-cover"
273 />
274 </div>
275 )}
276
277 {/* 右侧:内容 */}
278 <div className="flex-1 min-w-0">
279 {/* 头部:账户信息和平台图标 */}
280 <div className="flex items-center justify-between mb-1 sm:mb-1.5">
281 <div className="flex items-center gap-1 sm:gap-1.5 min-w-0 flex-1">
282 {account && (
283 <>
284 <AvatarPlat account={account} size="small" />
285 <span className="text-xs font-medium truncate max-w-[80px] sm:max-w-none">
286 {account.nickname}
287 </span>
288 </>
289 )}
290 </div>
291 {platInfo && (
292 <OssImage
293 src={platInfo.icon}
294 alt={platInfo.name}
295 width={16}
296 height={16}
297 className="rounded shrink-0 sm:w-[18px] sm:h-[18px]"
298 />
299 )}
300 </div>
301
302 {/* 标题 */}
303 <h4 className="text-xs sm:text-sm font-medium text-foreground line-clamp-1 mb-0.5 sm:mb-1">
304 {detail.title || initialData?.title || t('publishDetail.noTitle')}
305 </h4>
306
307 {/* 描述和话题 - 移动端隐藏或减少行数 */}
308 {description && (
309 <p className="text-xs text-muted-foreground line-clamp-1 sm:line-clamp-2 mb-1 sm:mb-1.5">
310 {description}
311 </p>
312 )}
313
314 {/* 状态和操作 - 移动端垂直堆叠 */}
315 <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
316 {renderStatusBadge()}
317
318 {/* 查看作品按钮 */}
319 {detail.status === PublishStatus.RELEASED && detail.workLink && (
320 <Button
321 variant="ghost"
322 size="sm"
323 className="h-6 px-2 text-xs cursor-pointer w-fit"
324 onClick={() => window.open(detail.workLink, '_blank')}
325 >
326 <ExternalLink className="w-3 h-3 mr-1" />
327 {t('publishDetail.viewWork')}
328 </Button>
329 )}
330 </div>
331
332 {/* 错误信息 */}
333 {detail.status === PublishStatus.FAIL && detail.errorMsg && (
334 <p className="text-xs text-destructive mt-1 line-clamp-1">{detail.errorMsg}</p>
335 )}
336
337 {/* 互动数据 - 移动端间距更小 */}
338 {renderEngagement()}
339 </div>
340 </div>
341 )
342 },
343 )
344
345 PublishDetailCard.displayName = 'PublishDetailCard'
346
347 export default PublishDetailCard
348
348 lines Plain Text