| 1 | /** |
| 2 | * ShareModal - 分享对话弹窗组件 |
| 3 | * 用于选择消息、生成分享链接和导出长图 |
| 4 | */ |
| 5 | 'use client' |
| 6 | |
| 7 | import type { TaskDetail } from '@/api/ai/ai.types' |
| 8 | import { Copy, Link2 } from 'lucide-react' |
| 9 | import { useRouter } from 'next/navigation' |
| 10 | import React, { useCallback, useEffect, useMemo, useState } from 'react' |
| 11 | import { agentApi } from '@/api/ai/ai.api' |
| 12 | import { PubType } from '@/app/config/publishConfig' |
| 13 | import { useTransClient } from '@/app/i18n/client' |
| 14 | |
| 15 | import ChatMessage from '@/components/Chat/ChatMessage' |
| 16 | import { convertMessages } from '@/components/Chat/utils' |
| 17 | import { usePublishDialogStorageStore } from '@/components/PublishDialog/usePublishDialogStorageStore' |
| 18 | import { Button } from '@/components/ui/button' |
| 19 | import { Checkbox } from '@/components/ui/checkbox' |
| 20 | import { |
| 21 | Dialog, |
| 22 | DialogContent, |
| 23 | DialogDescription, |
| 24 | DialogHeader, |
| 25 | DialogTitle, |
| 26 | DialogTrigger, |
| 27 | } from '@/components/ui/dialog' |
| 28 | import { |
| 29 | DropdownMenu, |
| 30 | DropdownMenuContent, |
| 31 | DropdownMenuItem, |
| 32 | DropdownMenuTrigger, |
| 33 | } from '@/components/ui/dropdown-menu' |
| 34 | import { |
| 35 | Select, |
| 36 | SelectContent, |
| 37 | SelectItem, |
| 38 | SelectTrigger, |
| 39 | SelectValue, |
| 40 | } from '@/components/ui/select' |
| 41 | import { Skeleton } from '@/components/ui/skeleton' |
| 42 | import { useAccountStore } from '@/store/account' |
| 43 | import { getPlatformInfoSync } from '@/store/platformMetadata' |
| 44 | import { useUserStore } from '@/store/user' |
| 45 | import { cn } from '@/utils/className' |
| 46 | import { toast } from '@/utils/ui/toast' |
| 47 | import { generateImageFromMessages } from './generateShareImages' |
| 48 | |
| 49 | interface ShareModalProps { |
| 50 | taskId: string |
| 51 | open?: boolean |
| 52 | onOpenChange?: (open: boolean) => void |
| 53 | trigger?: React.ReactNode |
| 54 | } |
| 55 | |
| 56 | // 有效期选项(秒) |
| 57 | const VALIDITY_OPTIONS = [ |
| 58 | { value: 86400, labelKey: 'validity1Day' }, |
| 59 | { value: 259200, labelKey: 'validity3Days' }, |
| 60 | { value: 604800, labelKey: 'validity7Days' }, |
| 61 | { value: 1296000, labelKey: 'validity15Days' }, |
| 62 | { value: 2592000, labelKey: 'validity30Days' }, |
| 63 | ] |
| 64 | |
| 65 | export function ShareModal({ taskId, open = false, onOpenChange, trigger }: ShareModalProps) { |
| 66 | const { t } = useTransClient('share') |
| 67 | const router = useRouter() |
| 68 | const [visible, setVisible] = useState(open) |
| 69 | const [loading, setLoading] = useState(false) |
| 70 | const [taskDetail, setTaskDetail] = useState<TaskDetail | null>(null) |
| 71 | const [selectedIds, setSelectedIds] = useState<string[]>([]) |
| 72 | const user = useUserStore(s => s.userInfo) |
| 73 | const { accountList, getAccountList } = useAccountStore() |
| 74 | |
| 75 | // 有效期状态(默认15天) |
| 76 | const [validitySeconds, setValiditySeconds] = useState(1296000) |
| 77 | |
| 78 | // 分享链接状态 |
| 79 | const [shareLink, setShareLink] = useState<string | null>(null) |
| 80 | const [shareExpiresAt, setShareExpiresAt] = useState<string | null>(null) |
| 81 | const [generatingLink, setGeneratingLink] = useState(false) |
| 82 | |
| 83 | // 图片生成状态 |
| 84 | const [generatingImage, setGeneratingImage] = useState(false) |
| 85 | |
| 86 | useEffect(() => { |
| 87 | setVisible(open) |
| 88 | }, [open]) |
| 89 | |
| 90 | // 重置分享链接状态(当有效期改变时) |
| 91 | useEffect(() => { |
| 92 | setShareLink(null) |
| 93 | setShareExpiresAt(null) |
| 94 | }, [validitySeconds]) |
| 95 | |
| 96 | useEffect(() => { |
| 97 | if (!taskId || !visible) |
| 98 | return |
| 99 | |
| 100 | const load = async () => { |
| 101 | try { |
| 102 | setLoading(true) |
| 103 | const res = await agentApi.getTaskDetail(taskId) |
| 104 | |
| 105 | if (res?.data) { |
| 106 | setTaskDetail(res.data) |
| 107 | const converted = convertMessages(res.data.messages || []) |
| 108 | const displayIds = converted.map(m => m.id) |
| 109 | setSelectedIds(displayIds) |
| 110 | setCollapsedIds(new Set(displayIds)) |
| 111 | } |
| 112 | else { |
| 113 | toast.error('Failed to load task detail: No data received') |
| 114 | } |
| 115 | } |
| 116 | catch (err) { |
| 117 | console.error('Failed to load task detail:', err) |
| 118 | const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' |
| 119 | toast.error(`Failed to load task detail: ${errorMessage}`) |
| 120 | } |
| 121 | finally { |
| 122 | setLoading(false) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | load() |
| 127 | }, [taskId, visible]) |
| 128 | |
| 129 | const messages = useMemo(() => { |
| 130 | if (!taskDetail?.messages) |
| 131 | return [] |
| 132 | return convertMessages(taskDetail.messages) |
| 133 | }, [taskDetail?.messages]) |
| 134 | |
| 135 | // 折叠状态 |
| 136 | const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set()) |
| 137 | const toggleCollapse = (id: string) => { |
| 138 | setCollapsedIds((prev) => { |
| 139 | const next = new Set(prev) |
| 140 | if (next.has(id)) |
| 141 | next.delete(id) |
| 142 | else next.add(id) |
| 143 | return next |
| 144 | }) |
| 145 | } |
| 146 | |
| 147 | const messageRefs = React.useRef<Map<string, HTMLDivElement>>(new Map()) |
| 148 | const [longIds, setLongIds] = useState<Set<string>>(new Set()) |
| 149 | // 测量完成标志,避免测量前后的高度突变导致抖动 |
| 150 | const [measured, setMeasured] = useState(false) |
| 151 | |
| 152 | React.useEffect(() => { |
| 153 | setMeasured(false) // 重置测量状态 |
| 154 | const measure = () => { |
| 155 | const next = new Set<string>() |
| 156 | messageRefs.current.forEach((el, id) => { |
| 157 | try { |
| 158 | if (el && el.scrollHeight > 60) |
| 159 | next.add(id) |
| 160 | } |
| 161 | catch { |
| 162 | // ignore |
| 163 | } |
| 164 | }) |
| 165 | setLongIds(next) |
| 166 | setMeasured(true) // 标记测量完成 |
| 167 | } |
| 168 | |
| 169 | const t = setTimeout(measure, 150) |
| 170 | const ro = new ResizeObserver(measure) |
| 171 | messageRefs.current.forEach((el) => { |
| 172 | if (el) |
| 173 | ro.observe(el) |
| 174 | }) |
| 175 | window.addEventListener('resize', measure) |
| 176 | return () => { |
| 177 | clearTimeout(t) |
| 178 | ro.disconnect() |
| 179 | window.removeEventListener('resize', measure) |
| 180 | } |
| 181 | }, [messages]) |
| 182 | |
| 183 | const toggleSelect = useCallback((id: string) => { |
| 184 | setSelectedIds((prev) => { |
| 185 | if (prev.includes(id)) |
| 186 | return prev.filter(p => p !== id) |
| 187 | return [...prev, id] |
| 188 | }) |
| 189 | }, []) |
| 190 | |
| 191 | const handleOpenChange = useCallback( |
| 192 | (open: boolean) => { |
| 193 | setVisible(open) |
| 194 | onOpenChange?.(open) |
| 195 | if (!open) { |
| 196 | // 重置状态 |
| 197 | setShareLink(null) |
| 198 | setShareExpiresAt(null) |
| 199 | } |
| 200 | }, |
| 201 | [onOpenChange], |
| 202 | ) |
| 203 | |
| 204 | // 生成分享链接 |
| 205 | const handleGenerateLink = useCallback(async () => { |
| 206 | setGeneratingLink(true) |
| 207 | try { |
| 208 | const res = await agentApi.createPublicShare(taskId, validitySeconds) |
| 209 | if (res?.data) { |
| 210 | const baseUrl |
| 211 | = typeof window !== 'undefined' ? window.location.origin : 'https://aitoearn.ai' |
| 212 | const fullUrl = `${baseUrl}/chat?token=${res.data.token}` |
| 213 | setShareLink(fullUrl) |
| 214 | setShareExpiresAt(res.data.expiresAt) |
| 215 | toast.success(t('linkCopied')) |
| 216 | // 自动复制到剪贴板 |
| 217 | await navigator.clipboard.writeText(fullUrl) |
| 218 | } |
| 219 | } |
| 220 | catch (err) { |
| 221 | console.error('Failed to generate share link:', err) |
| 222 | toast.error('Failed to generate share link') |
| 223 | } |
| 224 | finally { |
| 225 | setGeneratingLink(false) |
| 226 | } |
| 227 | }, [taskId, validitySeconds, t]) |
| 228 | |
| 229 | // 复制链接 |
| 230 | const handleCopyLink = useCallback(async () => { |
| 231 | if (!shareLink) |
| 232 | return |
| 233 | try { |
| 234 | await navigator.clipboard.writeText(shareLink) |
| 235 | toast.success(t('linkCopied')) |
| 236 | } |
| 237 | catch { |
| 238 | toast.error('Failed to copy link') |
| 239 | } |
| 240 | }, [shareLink, t]) |
| 241 | |
| 242 | // Agent 分享(传递链接) |
| 243 | const handleAgentShare = useCallback(async () => { |
| 244 | let link = shareLink |
| 245 | if (!link) { |
| 246 | // 先生成链接 |
| 247 | setGeneratingLink(true) |
| 248 | try { |
| 249 | const res = await agentApi.createPublicShare(taskId, validitySeconds) |
| 250 | if (res?.data) { |
| 251 | const baseUrl |
| 252 | = typeof window !== 'undefined' ? window.location.origin : 'https://aitoearn.ai' |
| 253 | link = `${baseUrl}/chat?token=${res.data.token}` |
| 254 | setShareLink(link) |
| 255 | setShareExpiresAt(res.data.expiresAt) |
| 256 | } |
| 257 | } |
| 258 | catch (err) { |
| 259 | console.error('Failed to generate share link:', err) |
| 260 | toast.error('Failed to generate share link') |
| 261 | return |
| 262 | } |
| 263 | finally { |
| 264 | setGeneratingLink(false) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | if (!link) |
| 269 | return |
| 270 | |
| 271 | const payloadPrompt = `${t('agentSharePrompt')}\n\n${link}` |
| 272 | const params = new URLSearchParams() |
| 273 | params.set('aiGenerated', 'true') |
| 274 | params.set('description', encodeURIComponent(payloadPrompt)) |
| 275 | |
| 276 | handleOpenChange(false) |
| 277 | router.push(`/?${params.toString()}`) |
| 278 | toast.success(t('agentShareSaved')) |
| 279 | }, [shareLink, taskId, validitySeconds, t, handleOpenChange, router]) |
| 280 | |
| 281 | // 发布分享(传递链接) |
| 282 | const handlePublishShare = useCallback(async () => { |
| 283 | let link = shareLink |
| 284 | if (!link) { |
| 285 | // 先生成链接 |
| 286 | setGeneratingLink(true) |
| 287 | try { |
| 288 | const res = await agentApi.createPublicShare(taskId, validitySeconds) |
| 289 | if (res?.data) { |
| 290 | const baseUrl |
| 291 | = typeof window !== 'undefined' ? window.location.origin : 'https://aitoearn.ai' |
| 292 | link = `${baseUrl}/chat?token=${res.data.token}` |
| 293 | setShareLink(link) |
| 294 | setShareExpiresAt(res.data.expiresAt) |
| 295 | } |
| 296 | } |
| 297 | catch (err) { |
| 298 | console.error('Failed to generate share link:', err) |
| 299 | toast.error('Failed to generate share link') |
| 300 | return |
| 301 | } |
| 302 | finally { |
| 303 | setGeneratingLink(false) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | if (!link) |
| 308 | return |
| 309 | |
| 310 | try { |
| 311 | usePublishDialogStorageStore.getState().clearPubData() |
| 312 | |
| 313 | if (!accountList || accountList.length === 0) { |
| 314 | await getAccountList() |
| 315 | } |
| 316 | |
| 317 | const candidates = (useAccountStore.getState().accountList || []).filter((acc) => { |
| 318 | const plat = getPlatformInfoSync(acc.type as any) |
| 319 | return acc.status !== 0 && plat?.pubTypes?.has(PubType.ImageText) |
| 320 | }) |
| 321 | |
| 322 | if (!candidates || candidates.length === 0) { |
| 323 | toast.error(t('noAvailablePublishAccounts')) |
| 324 | return |
| 325 | } |
| 326 | |
| 327 | const description = `${t('publishShareDescription')}\n\n${link}` |
| 328 | const tags = ['aitoearn', 'agent'] |
| 329 | |
| 330 | const params = new URLSearchParams() |
| 331 | params.set('aiGenerated', 'true') |
| 332 | params.set('description', encodeURIComponent(description)) |
| 333 | params.set('title', '') |
| 334 | params.set('tags', encodeURIComponent(JSON.stringify(tags))) |
| 335 | params.set('accountId', candidates[0].id) |
| 336 | |
| 337 | handleOpenChange(false) |
| 338 | router.push(`/accounts?${params.toString()}`) |
| 339 | } |
| 340 | catch (e) { |
| 341 | console.error(e) |
| 342 | toast.error(t('publishShareFailed') || 'Failed to prepare publish') |
| 343 | } |
| 344 | }, [shareLink, taskId, validitySeconds, accountList, getAccountList, t, handleOpenChange, router]) |
| 345 | |
| 346 | // 下载长图 |
| 347 | const handleDownloadImage = useCallback(async () => { |
| 348 | if (!messages || messages.length === 0) { |
| 349 | toast.error('No messages available') |
| 350 | return |
| 351 | } |
| 352 | |
| 353 | const selectedMessages = messages.filter(m => selectedIds.includes(m.id)) |
| 354 | if (selectedMessages.length === 0) { |
| 355 | toast.error('Please select at least one message to share') |
| 356 | return |
| 357 | } |
| 358 | |
| 359 | // 确保有分享链接 |
| 360 | let link = shareLink |
| 361 | let expiresAt = shareExpiresAt |
| 362 | if (!link) { |
| 363 | setGeneratingLink(true) |
| 364 | try { |
| 365 | const res = await agentApi.createPublicShare(taskId, validitySeconds) |
| 366 | if (res?.data) { |
| 367 | const baseUrl |
| 368 | = typeof window !== 'undefined' ? window.location.origin : 'https://aitoearn.ai' |
| 369 | link = `${baseUrl}/chat?token=${res.data.token}` |
| 370 | expiresAt = res.data.expiresAt |
| 371 | setShareLink(link) |
| 372 | setShareExpiresAt(expiresAt) |
| 373 | } |
| 374 | } |
| 375 | catch (err) { |
| 376 | console.error('Failed to generate share link:', err) |
| 377 | toast.error('Failed to generate share link') |
| 378 | return |
| 379 | } |
| 380 | finally { |
| 381 | setGeneratingLink(false) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | setGeneratingImage(true) |
| 386 | try { |
| 387 | const blobs = await generateImageFromMessages(selectedMessages, user?.name, { |
| 388 | appTitle: t('appName'), |
| 389 | appUrl: t('appUrl'), |
| 390 | shareUrl: link || undefined, |
| 391 | expiresAt: expiresAt || undefined, |
| 392 | }) |
| 393 | if (!blobs || blobs.length === 0) |
| 394 | throw new Error('No images were generated') |
| 395 | |
| 396 | // 直接下载 |
| 397 | for (let i = 0; i < blobs.length; i++) { |
| 398 | const blob = blobs[i] |
| 399 | const a = document.createElement('a') |
| 400 | const url = URL.createObjectURL(blob) |
| 401 | a.href = url |
| 402 | a.download |
| 403 | = blobs.length === 1 |
| 404 | ? `aitoearn_conversation_${taskId}.png` |
| 405 | : `aitoearn_${taskId}_${i + 1}.png` |
| 406 | document.body.appendChild(a) |
| 407 | a.click() |
| 408 | a.remove() |
| 409 | URL.revokeObjectURL(url) |
| 410 | } |
| 411 | toast.success(t('download')) |
| 412 | } |
| 413 | catch (err) { |
| 414 | console.error('Failed to generate images:', err) |
| 415 | const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' |
| 416 | toast.error(`Failed to generate images: ${errorMessage}`) |
| 417 | } |
| 418 | finally { |
| 419 | setGeneratingImage(false) |
| 420 | } |
| 421 | }, [messages, selectedIds, user?.name, t, shareLink, shareExpiresAt, taskId, validitySeconds]) |
| 422 | |
| 423 | const messageListContent = loading ? ( |
| 424 | <div className="space-y-4"> |
| 425 | {Array.from({ length: 6 }).map((_, i) => ( |
| 426 | <div key={i} className="flex items-start gap-3"> |
| 427 | <Skeleton className="w-10 h-10 rounded-full" /> |
| 428 | <div className="flex-1 space-y-2"> |
| 429 | <Skeleton className="h-4 w-1/4" /> |
| 430 | <Skeleton className="h-3 w-full" /> |
| 431 | <Skeleton className="h-3 w-5/6" /> |
| 432 | </div> |
| 433 | </div> |
| 434 | ))} |
| 435 | </div> |
| 436 | ) : ( |
| 437 | messages.map((message) => { |
| 438 | const isUser = message.role === 'user' |
| 439 | const isSelected = selectedIds.includes(message.id) |
| 440 | const isCollapsed = collapsedIds.has(message.id) |
| 441 | const isLong |
| 442 | = longIds.has(message.id) |
| 443 | || !!(message.content && message.content.length > 60) |
| 444 | || (message.medias && message.medias.length > 0) |
| 445 | |
| 446 | const checkbox = ( |
| 447 | <Checkbox |
| 448 | checked={isSelected} |
| 449 | onCheckedChange={(checked) => { |
| 450 | setSelectedIds(prev => checked === true |
| 451 | ? Array.from(new Set([...prev, message.id])) |
| 452 | : prev.filter(id => id !== message.id)) |
| 453 | }} |
| 454 | onClick={e => e.stopPropagation()} |
| 455 | onKeyDown={e => e.stopPropagation()} |
| 456 | className="h-4 w-4 cursor-pointer rounded-[5px] border-border bg-background text-muted-foreground shadow-sm data-[state=checked]:border-transparent data-[state=checked]:bg-gradient-back data-[state=checked]:text-gradient-foreground data-[state=checked]:shadow-primary/20" |
| 457 | /> |
| 458 | ) |
| 459 | |
| 460 | const onMessageClick = (e: React.MouseEvent) => { |
| 461 | const target = e.target as HTMLElement | null |
| 462 | if (!target) |
| 463 | return |
| 464 | if (target.closest('a,button,input,textarea,select,video')) |
| 465 | return |
| 466 | toggleSelect(message.id) |
| 467 | } |
| 468 | |
| 469 | return ( |
| 470 | <div key={message.id} className="relative"> |
| 471 | <div |
| 472 | onClick={onMessageClick} |
| 473 | role="button" |
| 474 | tabIndex={0} |
| 475 | onKeyDown={(e) => { |
| 476 | if (e.key === 'Enter' || e.key === ' ') |
| 477 | toggleSelect(message.id) |
| 478 | }} |
| 479 | className={cn( |
| 480 | 'flex items-start gap-2 md:gap-3 p-2.5 md:p-3 rounded-lg transition-colors cursor-pointer', |
| 481 | isSelected ? 'bg-muted/50 ring-1 ring-border/50' : 'bg-card hover:bg-muted/30', |
| 482 | )} |
| 483 | ref={(el) => { |
| 484 | if (el) |
| 485 | messageRefs.current.set(message.id, el as HTMLDivElement) |
| 486 | else messageRefs.current.delete(message.id) |
| 487 | }} |
| 488 | > |
| 489 | {!isUser && <div className="mt-1">{checkbox}</div>} |
| 490 | |
| 491 | <div className="flex-1 min-w-0"> |
| 492 | {/* 移动端不折叠,桌面端才应用折叠样式 */} |
| 493 | <div |
| 494 | className={cn( |
| 495 | measured && isLong && isCollapsed && 'md:max-h-10 md:overflow-hidden md:relative', |
| 496 | )} |
| 497 | > |
| 498 | <ChatMessage |
| 499 | role={message.role === 'system' ? 'assistant' : message.role} |
| 500 | content={message.content} |
| 501 | medias={message.medias} |
| 502 | status={message.status} |
| 503 | errorMessage={message.errorMessage} |
| 504 | createdAt={message.createdAt} |
| 505 | steps={message.steps} |
| 506 | actions={message.actions} |
| 507 | publishFlows={message.publishFlows} |
| 508 | className="max-w-full" |
| 509 | /> |
| 510 | {/* 渐变遮罩:移动端隐藏,桌面端显示 */} |
| 511 | {measured && isLong && isCollapsed && ( |
| 512 | <div className="hidden md:block absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-background to-transparent" /> |
| 513 | )} |
| 514 | </div> |
| 515 | </div> |
| 516 | |
| 517 | {isUser && <div className="mt-1">{checkbox}</div>} |
| 518 | </div> |
| 519 | |
| 520 | {/* 折叠按钮:移动端隐藏,桌面端显示 */} |
| 521 | {measured && isLong && ( |
| 522 | <button |
| 523 | type="button" |
| 524 | onClick={(e) => { |
| 525 | e.stopPropagation() |
| 526 | toggleCollapse(message.id) |
| 527 | }} |
| 528 | title={isCollapsed ? t('showMore') : t('collapse')} |
| 529 | className={cn( |
| 530 | 'hidden md:flex absolute top-3 z-20 w-9 h-9 items-center justify-center', |
| 531 | 'bg-background border border-border rounded-full shadow-sm', |
| 532 | 'hover:bg-muted/80 cursor-pointer transition-colors', |
| 533 | isUser ? 'left-3' : 'right-3', |
| 534 | )} |
| 535 | > |
| 536 | <span className="text-xs">{isCollapsed ? '▾' : '▴'}</span> |
| 537 | </button> |
| 538 | )} |
| 539 | </div> |
| 540 | ) |
| 541 | }) |
| 542 | ) |
| 543 | |
| 544 | return ( |
| 545 | <Dialog open={visible} onOpenChange={handleOpenChange}> |
| 546 | <DialogTrigger asChild>{trigger}</DialogTrigger> |
| 547 | {/* 移动端全屏,桌面端正常弹窗 */} |
| 548 | <DialogContent |
| 549 | // 移动端禁用 backdrop-blur 避免性能问题导致抖动 |
| 550 | overlayClassName="backdrop-blur-none md:backdrop-blur-sm" |
| 551 | className={cn( |
| 552 | // 使用 flex 布局让内部 flex-1 正常工作 |
| 553 | 'flex flex-col', |
| 554 | // 移动端全屏:禁用所有动画避免抖动 |
| 555 | 'inset-0 translate-x-0 translate-y-0 w-full h-[100svh] max-w-none rounded-none p-0 gap-0', |
| 556 | 'duration-0 animate-none', |
| 557 | 'data-[state=open]:zoom-in-0 data-[state=closed]:zoom-out-0', |
| 558 | 'data-[state=open]:slide-in-from-bottom-0 data-[state=closed]:slide-out-to-bottom-0', |
| 559 | // 桌面端恢复正常弹窗样式和动画 |
| 560 | 'md:inset-auto md:left-[50%] md:top-[50%] md:translate-x-[-50%] md:translate-y-[-50%]', |
| 561 | 'md:w-[95vw] md:max-w-5xl md:h-[85vh] md:max-h-[85vh] md:rounded-xl md:p-6', |
| 562 | 'md:duration-500 md:animate-in', |
| 563 | 'md:data-[state=open]:zoom-in-95 md:data-[state=closed]:zoom-out-95', |
| 564 | )} |
| 565 | > |
| 566 | <DialogHeader className="p-4 md:p-0 md:pb-4 border-b md:border-b-0 shrink-0"> |
| 567 | <DialogTitle className="text-base md:text-lg">{t('title')}</DialogTitle> |
| 568 | <DialogDescription className="text-xs md:text-sm">{t('description')}</DialogDescription> |
| 569 | </DialogHeader> |
| 570 | |
| 571 | <div className="flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden [contain:strict]"> |
| 572 | {/* 左侧:消息选择区域 */} |
| 573 | <div className="flex-1 flex flex-col min-h-0 min-w-0 p-4 md:pr-4 [contain:layout]"> |
| 574 | {/* 操作按钮区域 - 移动端更紧凑 */} |
| 575 | <div className="flex items-center justify-between gap-2 p-2 md:p-3 border rounded-lg bg-muted/30 mb-3 md:mb-4 shrink-0"> |
| 576 | <div className="flex items-center gap-1 md:gap-2 flex-wrap"> |
| 577 | <Button |
| 578 | variant="outline" |
| 579 | size="sm" |
| 580 | onClick={() => { |
| 581 | const allIds = messages.map(m => m.id) |
| 582 | setSelectedIds(allIds) |
| 583 | }} |
| 584 | className="cursor-pointer text-xs h-7 px-2 md:px-3" |
| 585 | > |
| 586 | {t('selectAll')} |
| 587 | </Button> |
| 588 | |
| 589 | <DropdownMenu> |
| 590 | <DropdownMenuTrigger asChild> |
| 591 | <Button |
| 592 | variant="outline" |
| 593 | size="sm" |
| 594 | className="cursor-pointer text-xs h-7 px-2 md:px-3" |
| 595 | > |
| 596 | {t('selectRecent')} |
| 597 | </Button> |
| 598 | </DropdownMenuTrigger> |
| 599 | <DropdownMenuContent> |
| 600 | <DropdownMenuItem |
| 601 | onClick={() => { |
| 602 | const recentCount = Math.min(5, messages.length) |
| 603 | const recentIds = messages.slice(-recentCount).map(m => m.id) |
| 604 | setSelectedIds(recentIds) |
| 605 | }} |
| 606 | > |
| 607 | {t('recent5')} |
| 608 | </DropdownMenuItem> |
| 609 | <DropdownMenuItem |
| 610 | onClick={() => { |
| 611 | const recentCount = Math.min(10, messages.length) |
| 612 | const recentIds = messages.slice(-recentCount).map(m => m.id) |
| 613 | setSelectedIds(recentIds) |
| 614 | }} |
| 615 | > |
| 616 | {t('recent10')} |
| 617 | </DropdownMenuItem> |
| 618 | <DropdownMenuItem |
| 619 | onClick={() => { |
| 620 | const recentCount = Math.min(20, messages.length) |
| 621 | const recentIds = messages.slice(-recentCount).map(m => m.id) |
| 622 | setSelectedIds(recentIds) |
| 623 | }} |
| 624 | > |
| 625 | {t('recent20')} |
| 626 | </DropdownMenuItem> |
| 627 | </DropdownMenuContent> |
| 628 | </DropdownMenu> |
| 629 | |
| 630 | <Button |
| 631 | variant="outline" |
| 632 | size="sm" |
| 633 | onClick={() => setSelectedIds([])} |
| 634 | className="cursor-pointer text-xs h-7 px-2 md:px-3" |
| 635 | > |
| 636 | {t('clearSelection')} |
| 637 | </Button> |
| 638 | </div> |
| 639 | |
| 640 | <div className="text-xs text-muted-foreground whitespace-nowrap"> |
| 641 | {selectedIds.length} |
| 642 | </div> |
| 643 | </div> |
| 644 | |
| 645 | {/* 消息列表 - 阻止滚动事件冒泡到 Radix 滚动锁定层 */} |
| 646 | <div |
| 647 | className="flex-1 min-h-0 overflow-y-auto overscroll-contain border rounded-lg p-3 md:p-4 space-y-3 md:space-y-4" |
| 648 | onWheel={e => e.stopPropagation()} |
| 649 | onTouchMove={e => e.stopPropagation()} |
| 650 | > |
| 651 | {messageListContent} |
| 652 | </div> |
| 653 | </div> |
| 654 | |
| 655 | {/* 右侧:配置和操作区域 - 移动端底部固定 */} |
| 656 | <div className="w-full md:w-72 shrink-0 border-t md:border-t-0 md:border-l bg-background p-4 md:overflow-y-auto"> |
| 657 | {/* 移动端:水平紧凑布局 */} |
| 658 | <div className="md:hidden space-y-2"> |
| 659 | {/* 有效期 + 链接 一行 */} |
| 660 | <div className="flex items-center gap-2"> |
| 661 | <Select |
| 662 | value={String(validitySeconds)} |
| 663 | onValueChange={v => setValiditySeconds(Number(v))} |
| 664 | > |
| 665 | <SelectTrigger className="cursor-pointer h-8 text-xs flex-1"> |
| 666 | <SelectValue /> |
| 667 | </SelectTrigger> |
| 668 | <SelectContent> |
| 669 | {VALIDITY_OPTIONS.map(opt => ( |
| 670 | <SelectItem key={opt.value} value={String(opt.value)}> |
| 671 | {t(opt.labelKey)} |
| 672 | </SelectItem> |
| 673 | ))} |
| 674 | </SelectContent> |
| 675 | </Select> |
| 676 | |
| 677 | {/* 复制链接按钮 - 使用主题色更明显 */} |
| 678 | {shareLink ? ( |
| 679 | <Button |
| 680 | variant="default" |
| 681 | size="sm" |
| 682 | onClick={handleCopyLink} |
| 683 | className="cursor-pointer h-8 px-2.5 gap-1" |
| 684 | > |
| 685 | <Copy className="w-3.5 h-3.5" /> |
| 686 | <span className="text-xs">{t('copyLink')}</span> |
| 687 | </Button> |
| 688 | ) : ( |
| 689 | <Button |
| 690 | variant="default" |
| 691 | size="sm" |
| 692 | onClick={handleGenerateLink} |
| 693 | loading={generatingLink} |
| 694 | className="cursor-pointer h-8 px-2.5 gap-1" |
| 695 | > |
| 696 | <Link2 className="w-3.5 h-3.5" /> |
| 697 | <span className="text-xs">{t('copyLink')}</span> |
| 698 | </Button> |
| 699 | )} |
| 700 | </div> |
| 701 | |
| 702 | {/* 操作按钮 一行 */} |
| 703 | <div className="flex items-center gap-2"> |
| 704 | <Button |
| 705 | variant="outline" |
| 706 | size="sm" |
| 707 | onClick={handleAgentShare} |
| 708 | loading={generatingLink} |
| 709 | className="flex-1 cursor-pointer h-8 text-xs" |
| 710 | > |
| 711 | {t('agentShare')} |
| 712 | </Button> |
| 713 | <Button |
| 714 | variant="outline" |
| 715 | size="sm" |
| 716 | onClick={handlePublishShare} |
| 717 | loading={generatingLink} |
| 718 | className="flex-1 cursor-pointer h-8 text-xs" |
| 719 | > |
| 720 | {t('publishShare')} |
| 721 | </Button> |
| 722 | </div> |
| 723 | |
| 724 | {/* 主操作按钮 一行 */} |
| 725 | <div className="flex items-center gap-2"> |
| 726 | <Button |
| 727 | size="sm" |
| 728 | onClick={handleDownloadImage} |
| 729 | disabled={selectedIds.length === 0} |
| 730 | loading={generatingImage || generatingLink} |
| 731 | className="flex-1 cursor-pointer h-9 text-xs" |
| 732 | > |
| 733 | {generatingImage ? t('generating') : t('downloadImage')} |
| 734 | </Button> |
| 735 | <Button |
| 736 | variant="secondary" |
| 737 | size="sm" |
| 738 | onClick={() => handleOpenChange(false)} |
| 739 | className="cursor-pointer h-9 px-3" |
| 740 | > |
| 741 | {t('cancel')} |
| 742 | </Button> |
| 743 | </div> |
| 744 | </div> |
| 745 | |
| 746 | {/* 桌面端:原有垂直布局 */} |
| 747 | <div className="hidden md:flex md:flex-col md:gap-4 md:h-full"> |
| 748 | {/* 有效期选择 */} |
| 749 | <div className="border rounded-lg p-4 space-y-3"> |
| 750 | <div className="text-sm font-medium">{t('validityLabel')}</div> |
| 751 | <Select |
| 752 | value={String(validitySeconds)} |
| 753 | onValueChange={v => setValiditySeconds(Number(v))} |
| 754 | > |
| 755 | <SelectTrigger className="cursor-pointer"> |
| 756 | <SelectValue /> |
| 757 | </SelectTrigger> |
| 758 | <SelectContent> |
| 759 | {VALIDITY_OPTIONS.map(opt => ( |
| 760 | <SelectItem key={opt.value} value={String(opt.value)}> |
| 761 | {t(opt.labelKey)} |
| 762 | </SelectItem> |
| 763 | ))} |
| 764 | </SelectContent> |
| 765 | </Select> |
| 766 | </div> |
| 767 | |
| 768 | {/* 分享链接 */} |
| 769 | <div className="border rounded-lg p-4 space-y-3"> |
| 770 | <div className="text-sm font-medium flex items-center gap-2"> |
| 771 | <Link2 className="w-4 h-4" /> |
| 772 | {t('shareLink')} |
| 773 | </div> |
| 774 | |
| 775 | {shareLink ? ( |
| 776 | <div className="space-y-2"> |
| 777 | <div className="text-xs text-muted-foreground break-all bg-muted/50 p-2 rounded"> |
| 778 | {shareLink} |
| 779 | </div> |
| 780 | {shareExpiresAt && ( |
| 781 | <div className="text-xs text-muted-foreground"> |
| 782 | {t('expiresAt')} |
| 783 | : |
| 784 | {new Date(shareExpiresAt).toLocaleString()} |
| 785 | </div> |
| 786 | )} |
| 787 | <Button |
| 788 | variant="outline" |
| 789 | size="sm" |
| 790 | onClick={handleCopyLink} |
| 791 | className="w-full cursor-pointer" |
| 792 | > |
| 793 | <Copy className="w-4 h-4 mr-2" /> |
| 794 | {t('copyLink')} |
| 795 | </Button> |
| 796 | </div> |
| 797 | ) : ( |
| 798 | <Button |
| 799 | variant="outline" |
| 800 | size="sm" |
| 801 | onClick={handleGenerateLink} |
| 802 | loading={generatingLink} |
| 803 | className="w-full cursor-pointer" |
| 804 | > |
| 805 | {generatingLink ? t('generatingLink') : t('copyLink')} |
| 806 | </Button> |
| 807 | )} |
| 808 | </div> |
| 809 | |
| 810 | {/* 分享方式 */} |
| 811 | <div className="border rounded-lg p-4 space-y-3"> |
| 812 | <div className="text-sm font-medium">{t('shareActions')}</div> |
| 813 | <div className="space-y-2"> |
| 814 | <Button |
| 815 | variant="outline" |
| 816 | size="sm" |
| 817 | onClick={handleAgentShare} |
| 818 | loading={generatingLink} |
| 819 | className="w-full cursor-pointer" |
| 820 | > |
| 821 | {t('agentShare')} |
| 822 | </Button> |
| 823 | <Button |
| 824 | variant="outline" |
| 825 | size="sm" |
| 826 | onClick={handlePublishShare} |
| 827 | loading={generatingLink} |
| 828 | className="w-full cursor-pointer" |
| 829 | > |
| 830 | {t('publishShare')} |
| 831 | </Button> |
| 832 | </div> |
| 833 | </div> |
| 834 | |
| 835 | {/* 下载长图 */} |
| 836 | <Button |
| 837 | onClick={handleDownloadImage} |
| 838 | disabled={selectedIds.length === 0} |
| 839 | loading={generatingImage || generatingLink} |
| 840 | className="w-full cursor-pointer" |
| 841 | > |
| 842 | {generatingImage ? t('generating') : t('downloadImage')} |
| 843 | </Button> |
| 844 | |
| 845 | {/* 取消按钮 */} |
| 846 | <Button |
| 847 | variant="secondary" |
| 848 | onClick={() => handleOpenChange(false)} |
| 849 | className="w-full cursor-pointer" |
| 850 | > |
| 851 | {t('cancel')} |
| 852 | </Button> |
| 853 | </div> |
| 854 | </div> |
| 855 | </div> |
| 856 | </DialogContent> |
| 857 | </Dialog> |
| 858 | ) |
| 859 | } |
| 860 | |
| 861 | export default ShareModal |
| 862 |