返回 AiToEarn
MobileContent.tsx
1 /**
2 * MobileContent - 创建/编辑素材弹窗的移动端布局组件
3 * 全屏弹窗,布局:平台选择 → 媒体上传区 → 标题输入 → 描述输入
4 */
5 'use client'
6
7 import type { CreateMaterialModalProps } from './index'
8 import type { IImgFile, IVideoFile } from '@/components/PublishDialog/publishDialog.type'
9 import { Bot, Play, X } from 'lucide-react'
10 import Image from 'next/image'
11 import { useRouter } from 'next/navigation'
12 import { memo, useCallback, useMemo, useState } from 'react'
13 import { useTranslation } from 'react-i18next'
14 import { ReactSortable } from 'react-sortablejs'
15 import { CSSTransition, TransitionGroup } from 'react-transition-group'
16 import { useTransClient } from '@/app/i18n/client'
17 import MediaPreview from '@/components/common/MediaPreview'
18 import BrushEditor from '@/components/common/MediaPreview/BrushEditor'
19 import PublishUploadProgress from '@/components/PublishDialog/compoents/PublishManageUpload/PublishUploadProgress'
20 import PubParmasMentionInput from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasMentionInput'
21 import PubParmasTextareaUpload from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasTextareaUpload'
22 import PubParmasTextuploadImage from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasTextuploadImage'
23 import VideoCoverSeting from '@/components/PublishDialog/compoents/PubParmasTextarea/VideoCoverSeting'
24 import { formatImg } from '@/components/PublishDialog/PublishDialog.util'
25 import { Button } from '@/components/ui/button'
26 import { DialogTitle } from '@/components/ui/dialog'
27 import { toast } from '@/utils/ui/toast'
28 import InlinePlatformSelector from './InlinePlatformSelector'
29 import { MaterialValidationAlert } from './MaterialValidationAlert'
30 import { useCreateMaterialForm } from './useCreateMaterialForm'
31 import { useMaterialValidation } from './useMaterialValidation'
32 import './uploadItemTransition.css'
33
34 const MobileContent = memo(
35 ({
36 groupId,
37 editingMaterial,
38 isSubmitting: externalSubmitting,
39 onClose,
40 onSuccess,
41 }: Omit<CreateMaterialModalProps, 'open'>) => {
42 const { t } = useTranslation('brandPromotion')
43 const { t: tPublish } = useTransClient('publish')
44 const router = useRouter()
45
46 const {
47 params,
48 updateParams,
49 updateImages,
50 updateVideo,
51 validationIssues,
52 isSubmitting: submitting,
53 handleSubmit,
54 cancelUpload,
55 } = useCreateMaterialForm({
56 groupId,
57 editingMaterial,
58 isSubmitting: externalSubmitting,
59 onClose,
60 onSuccess,
61 })
62
63 const { effectiveLimits } = useMaterialValidation(params.selectedPlatforms)
64
65 // 预览状态
66 const [previewData, setPreviewData] = useState<IImgFile | IVideoFile | undefined>(undefined)
67 const [imagePreviewOpen, setImagePreviewOpen] = useState(false)
68 // 编辑图片索引
69 const [editImgIndex, setEditImgIndex] = useState(-1)
70 // 视频封面裁剪
71 const [videoCoverSetingModal, setVideoCoverSetingModal] = useState(false)
72 // 内部图片/视频状态(用于排序等操作后同步回 params)
73
74 const videoMax = 1
75
76 // 动态 accept 类型
77 const uploadAccept = useMemo(() => {
78 const hasImage = params.images.length !== 0
79 const hasVideo = !!params.video
80 if (hasImage && !hasVideo)
81 return 'image/*'
82 if (!hasImage && hasVideo)
83 return 'video/*'
84 return 'video/*,image/*'
85 }, [params.images, params.video])
86
87 // 是否显示上传按钮
88 const canShowDragger = useMemo(() => {
89 const videoCount = params.video ? 1 : 0
90 return videoCount < videoMax
91 }, [params.video, videoMax])
92
93 // 文件类型检查
94 const checkFileListType = useCallback(
95 (fileList: File[]) => {
96 const hasImage = params.images.length !== 0
97 const hasVideo = !!params.video
98 let uploadHasImage = false
99 let uploadHasVideo = false
100 let invalidFile = false
101
102 for (const file of fileList) {
103 if (file.type.startsWith('image/')) {
104 uploadHasImage = true
105 }
106 else if (file.type.startsWith('video/')) {
107 uploadHasVideo = true
108 }
109 else {
110 invalidFile = true
111 }
112 }
113
114 const messageOpen = (content: string) => {
115 toast.warning(content, { id: 'upload-warning' })
116 }
117
118 if (hasImage && !hasVideo && uploadHasVideo) {
119 messageOpen(tPublish('validation.imageOnly'))
120 return false
121 }
122 if (hasVideo && !hasImage && uploadHasImage) {
123 messageOpen(tPublish('validation.videoOnly'))
124 return false
125 }
126 if (
127 (uploadHasImage && uploadHasVideo)
128 || (hasImage && uploadHasVideo)
129 || (hasVideo && uploadHasImage)
130 ) {
131 messageOpen(tPublish('validation.imageVideoMixed'))
132 return false
133 }
134 if (invalidFile) {
135 messageOpen(tPublish('validation.onlyImageOrVideo'))
136 return false
137 }
138 if (uploadHasVideo) {
139 const totalVideoCount
140 = (params.video ? 1 : 0) + fileList.filter(f => f.type.startsWith('video/')).length
141 if (totalVideoCount > videoMax) {
142 messageOpen(tPublish('validation.videoMaxExceeded', { maxCount: videoMax }))
143 return false
144 }
145 }
146 return true
147 },
148 [params.images, params.video, videoMax, tPublish],
149 )
150
151 // 图片列表排序回调
152 const handleSortList = useCallback(
153 (newList: IImgFile[]) => {
154 updateParams({ images: newList })
155 },
156 [updateParams],
157 )
158
159 return (
160 <>
161 {/* 弹窗层 */}
162 <VideoCoverSeting
163 videoCoverSetingModal={videoCoverSetingModal}
164 onClose={() => setVideoCoverSetingModal(false)}
165 videoFile={params.video}
166 value={params.video?.cover}
167 onChoosed={(newCover) => {
168 updateParams({
169 video: params.video ? { ...params.video, cover: newCover } : undefined,
170 })
171 }}
172 />
173
174 <MediaPreview
175 open={imagePreviewOpen && !!(previewData && (previewData as IImgFile).imgUrl)}
176 items={[{ type: 'image', src: (previewData as IImgFile)?.imgUrl || '' }]}
177 onClose={() => {
178 setImagePreviewOpen(false)
179 setPreviewData(undefined)
180 }}
181 />
182
183 <MediaPreview
184 open={!!(previewData && (previewData as IVideoFile)?.videoUrl)}
185 items={[{ type: 'video', src: (previewData as IVideoFile)?.videoUrl }]}
186 onClose={() => setPreviewData(undefined)}
187 />
188
189 <BrushEditor
190 open={editImgIndex !== -1}
191 imageUrl={params.images[editImgIndex]?.imgUrl || ''}
192 onClose={() => setEditImgIndex(-1)}
193 onSave={async (newUrl, blob) => {
194 const image = await formatImg({
195 blob,
196 path: params.images[editImgIndex]?.filename || `edited_${Date.now()}.png`,
197 })
198 image.ossUrl = newUrl
199 const newImages = [...params.images]
200 newImages[editImgIndex] = image
201 updateParams({ images: newImages })
202 }}
203 />
204
205 {/* 顶部栏 */}
206 <div className="flex items-center justify-between px-4 h-12 border-b border-border shrink-0">
207 <button
208 type="button"
209 className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-accent cursor-pointer"
210 onClick={onClose}
211 >
212 <X className="h-5 w-5" />
213 </button>
214 <DialogTitle className="text-base font-medium">{t('createMaterial.title')}</DialogTitle>
215 <div className="w-8" />
216 </div>
217
218 {/* 可滚动内容区 */}
219 <div className="flex-1 overflow-y-auto px-4 py-4">
220 {/* 平台选择器 */}
221 <div className="mb-4 border-b border-border pb-3">
222 <InlinePlatformSelector
223 selectedPlatforms={params.selectedPlatforms}
224 onPlatformsChange={platforms => updateParams({ selectedPlatforms: platforms })}
225 />
226 </div>
227
228 {/* 提交后平台兼容性问题 */}
229 {validationIssues.length > 0 && (
230 <div className="mb-4">
231 <MaterialValidationAlert issues={validationIssues} />
232 </div>
233 )}
234
235 {/* 媒体上传区 */}
236 <ReactSortable
237 className="grid grid-cols-3 gap-2.5"
238 list={params.images}
239 animation={250}
240 setList={handleSortList}
241 scrollSensitivity={100}
242 scrollSpeed={15}
243 id="id"
244 >
245 <TransitionGroup component={null}>
246 {/* 图片列表 */}
247 {params.images.map((v, i) => (
248 <CSSTransition key={v.id || v.imgUrl} timeout={300} classNames="upload-item">
249 <PubParmasTextuploadImage
250 onEditClick={() => setEditImgIndex(i)}
251 imageFile={v}
252 onClick={() => {
253 setPreviewData(v)
254 setImagePreviewOpen(true)
255 }}
256 onClose={() => {
257 updateImages((prevImages) => {
258 const target = prevImages[i]
259 if (target?.uploadTaskId)
260 cancelUpload(target.uploadTaskId)
261 return prevImages.filter((_, idx) => idx !== i)
262 })
263 }}
264 />
265 </CSSTransition>
266 ))}
267
268 {/* 视频列表 */}
269 {(params.video ? [params.video] : []).map((v, i) => (
270 <CSSTransition key={`video-${i}`} timeout={300} classNames="upload-item">
271 <div
272 className="h-[110px] border border-border rounded-lg cursor-pointer relative overflow-hidden"
273 onClick={() => setPreviewData(params.video)}
274 >
275 <div
276 className="absolute right-1 top-1 z-20 w-5 h-5 bg-black/70 text-white rounded-full flex items-center justify-center cursor-pointer transition-all hover:bg-black/90 hover:scale-110"
277 onClick={(e) => {
278 e.stopPropagation()
279 const uploadIds = params.video?.uploadTaskIds
280 if (uploadIds?.video)
281 cancelUpload(uploadIds.video)
282 if (uploadIds?.cover)
283 cancelUpload(uploadIds.cover)
284 updateParams({ video: undefined })
285 }}
286 >
287 <X className="h-3 w-3" />
288 </div>
289 <div className="w-full h-full relative">
290 <Image
291 src={v.cover?.imgUrl || ''}
292 width={120}
293 height={120}
294 className="w-full h-full object-cover"
295 alt=""
296 unoptimized
297 />
298 <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-6 bg-white/60 rounded-full flex items-center justify-center text-white">
299 <Play className="h-4 w-4 fill-current" />
300 </div>
301 </div>
302 {params.video?.uploadTaskIds?.video && (
303 <PublishUploadProgress taskId={params.video.uploadTaskIds.video} />
304 )}
305 </div>
306 </CSSTransition>
307 ))}
308
309 {/* 上传按钮 */}
310 {canShowDragger && (
311 <CSSTransition key="dragger" timeout={300} classNames="upload-item" unmountOnExit>
312 <PubParmasTextareaUpload
313 checkFileListType={checkFileListType}
314 uploadAccept={uploadAccept}
315 enableGlobalDrag={false}
316 onVideoUpdateFinish={(video) => {
317 updateVideo((prevVideo) => {
318 if (prevVideo) {
319 const prevIds = prevVideo.uploadTaskIds ?? {}
320 const nextIds = video?.uploadTaskIds ?? {}
321 if (prevIds.video && prevIds.video !== nextIds.video)
322 cancelUpload(prevIds.video)
323 if (prevIds.cover && prevIds.cover !== nextIds.cover)
324 cancelUpload(prevIds.cover)
325 }
326 return video
327 })
328 }}
329 onImgUpdateFinish={(imgs) => {
330 updateImages((prevImages) => {
331 const next = [...prevImages]
332 imgs.forEach((img) => {
333 const index = next.findIndex(item => item.id === img.id)
334 if (index !== -1) {
335 next[index] = img
336 }
337 else {
338 next.push(img)
339 }
340 })
341 return next
342 })
343 }}
344 />
345 </CSSTransition>
346 )}
347 </TransitionGroup>
348 </ReactSortable>
349
350 {/* 视频封面裁剪按钮 */}
351 {params.video && (
352 <Button
353 variant="outline"
354 size="sm"
355 className="mt-2.5 cursor-pointer"
356 onClick={() => setVideoCoverSetingModal(true)}
357 >
358 {tPublish('actions.cropCover')}
359 </Button>
360 )}
361
362 {/* 标题输入 */}
363 <div className="mt-4 border-t border-border pt-4">
364 <div className="flex items-center gap-2">
365 <input
366 type="text"
367 value={params.title}
368 placeholder={t('createMaterial.titlePlaceholder')}
369 onChange={e => updateParams({ title: e.target.value })}
370 className="flex-1 min-w-0 text-base font-medium bg-transparent border-none shadow-none outline-none placeholder:text-muted-foreground/50 focus-visible:ring-0"
371 />
372 {effectiveLimits.titleMax && (
373 <span
374 className={`shrink-0 text-xs tabular-nums ${params.title.length > effectiveLimits.titleMax.value ? 'text-destructive' : 'text-muted-foreground'}`}
375 >
376 {params.title.length}
377 /
378 {effectiveLimits.titleMax.value}
379 </span>
380 )}
381 </div>
382 </div>
383
384 {/* 描述输入 */}
385 <div className="mt-3 border-t border-border pt-3">
386 <PubParmasMentionInput
387 value={params.des}
388 onChange={(value: string) => updateParams({ des: value })}
389 placeholder={tPublish('form.descriptionPlaceholder')}
390 maxLength={2200}
391 />
392 </div>
393 </div>
394
395 {/* 底部栏 */}
396 <div className="flex items-center justify-between px-4 h-14 border-t border-border shrink-0">
397 <Button
398 variant="ghost"
399 size="sm"
400 className="cursor-pointer transition-all hover:bg-gradient-to-r hover:from-purple-500/10 hover:to-blue-500/10"
401 onClick={(e) => {
402 e.stopPropagation()
403 router.push(
404 `/ai-social?agentExternalPrompt=${encodeURIComponent(t('detail.agentGeneratePrompt'))}`,
405 )
406 }}
407 >
408 <Bot className="mr-1 h-4 w-4" />
409 {t('detail.agentGenerate')}
410 </Button>
411 <Button onClick={handleSubmit} disabled={submitting} className="cursor-pointer">
412 {t('common.confirm')}
413 </Button>
414 </div>
415 </>
416 )
417 },
418 )
419
420 MobileContent.displayName = 'MobileContent'
421
422 export default MobileContent
423
423 lines Plain Text