返回 AiToEarn
ShareModal.tsx
根目录 / project / aitoearn-web / src / components / Chat / Share / ShareModal.tsx
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
862 lines Plain Text