返回 AiToEarn
1 /**
2 * PubParmasTextarea - 发布参数文本输入区域
3 * 包含描述输入、图片/视频上传、草稿选择等功能
4 */
5 import type { CSSProperties, ForwardedRef } from 'react'
6 import type { PlatType } from '@/app/config/platConfig'
7 import type { IImgFile, IVideoFile } from '@/components/PublishDialog/publishDialog.type'
8 import { Play, X } from 'lucide-react'
9 import React, { forwardRef, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
10 import { ReactSortable } from 'react-sortablejs'
11 import { CSSTransition, TransitionGroup } from 'react-transition-group'
12 import { useShallow } from 'zustand/react/shallow'
13 import { PubType } from '@/app/config/publishConfig'
14 import { useTransClient } from '@/app/i18n/client'
15 import MediaPreview from '@/components/common/MediaPreview'
16 import BrushEditor from '@/components/common/MediaPreview/BrushEditor'
17 import PublishUploadProgress from '@/components/PublishDialog/compoents/PublishManageUpload/PublishUploadProgress'
18 import { usePublishManageUpload } from '@/components/PublishDialog/compoents/PublishManageUpload/usePublishManageUpload'
19 import PubParmasMentionInput from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasMentionInput'
20 import PubParmasTextareaUpload from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasTextareaUpload'
21 import PubParmasTextuploadImage from '@/components/PublishDialog/compoents/PubParmasTextarea/PubParmasTextuploadImage'
22 import VideoCoverSeting from '@/components/PublishDialog/compoents/PubParmasTextarea/VideoCoverSeting'
23 import { formatImg } from '@/components/PublishDialog/PublishDialog.util'
24 import { Button } from '@/components/ui/button'
25 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
26 import { usePlatformInfo } from '@/hooks/usePlatformMetadata'
27 import { cn } from '@/utils/className'
28 import { toast } from '@/utils/ui/toast'
29 import './uploadItemTransition.css'
30
31 export interface IPubParmasTextareaRef {}
32
33 export interface IChangeParams {
34 imgs?: IImgFile[]
35 video?: IVideoFile
36 value: string
37 }
38
39 export interface IPubParmasTextareaProps {
40 onChange?: (values: IChangeParams) => void
41 rows?: number
42 // 视频数量限制
43 videoMax?: number
44 // 扩展内容
45 extend?: React.ReactNode
46 // 在前面的扩展元素
47 beforeExtend?: React.ReactNode
48 // 在中间的扩展元素
49 centerExtend?: React.ReactNode
50 // 平台类型
51 platType: PlatType
52 style?: CSSProperties
53 imageFileListValue?: IImgFile[]
54 videoFileValue?: IVideoFile
55 desValue?: string
56 // 是否为移动端
57 isMobile?: boolean
58 // toolbar 额外内容
59 toolbarExtra?: React.ReactNode
60 // 覆盖图片最大数量(优先于平台配置)
61 imagesMaxOverride?: number
62 // 覆盖描述最大长度(优先于平台配置)
63 desMaxOverride?: number
64 // 跳过平台兼容性前置限制,用于草稿先输入后校验的场景
65 disableUploadCompatibilityCheck?: boolean
66 }
67
68 const PubParmasTextarea = memo(
69 forwardRef(
70 (
71 {
72 style,
73 onChange,
74 rows = 12,
75 videoMax = 1,
76 extend,
77 centerExtend,
78 imageFileListValue = [],
79 videoFileValue,
80 desValue = '',
81 beforeExtend,
82 platType,
83 isMobile,
84 toolbarExtra,
85 imagesMaxOverride,
86 desMaxOverride,
87 disableUploadCompatibilityCheck,
88 }: IPubParmasTextareaProps,
89 ref: ForwardedRef<IPubParmasTextareaRef>,
90 ) => {
91 const [value, setValue] = useState(desValue)
92 const [previewData, setPreviewData] = useState<IImgFile | IVideoFile | undefined>(undefined)
93 // 图片
94 const [imageFileList, setImageFileList] = useState<IImgFile[]>(imageFileListValue)
95 // 视频
96 const [videoFile, setVideoFile] = useState<IVideoFile | undefined>(videoFileValue)
97 // 裁剪弹框
98 const [videoCoverSetingModal, setVideoCoverSetingModal] = useState(false)
99 const isFirst = useRef({
100 effect: true,
101 sort: true,
102 })
103 // 记录最近一次从 props 同步过来的值,用于在 onChange effect 中判断是否为 props 触发的变化
104 const lastSyncedFromProps = useRef({
105 des: desValue || '',
106 images: imageFileListValue ?? [],
107 video: videoFileValue,
108 })
109 const { t } = useTransClient('publish')
110 // 编辑图片的索引
111 const [editImgIndex, setEditImgIndex] = useState(-1)
112 const { cancelUpload } = usePublishManageUpload(
113 useShallow(state => ({
114 cancelUpload: state.cancelUpload,
115 })),
116 )
117
118 // 图片预览状态
119 const [imagePreviewOpen, setImagePreviewOpen] = useState(false)
120
121 useLayoutEffect(() => {
122 if (isFirst.current.effect) {
123 isFirst.current.effect = false
124 return
125 }
126 // 如果当前值和最近 props 同步的值完全一致,说明是 props 变化引起的,跳过
127 const synced = lastSyncedFromProps.current
128 if (
129 value === synced.des
130 && imageFileList === synced.images
131 && videoFile === synced.video
132 ) {
133 return
134 }
135 const values = {
136 imgs: imageFileList,
137 video: videoFile,
138 value,
139 }
140 if (onChange)
141 onChange(values)
142 }, [imageFileList, videoFile, value])
143 // 合并外部 props 同步,记录同步值用于 onChange effect 中判断
144 useEffect(() => {
145 lastSyncedFromProps.current = {
146 des: desValue || '',
147 images: imageFileListValue ?? [],
148 video: videoFileValue,
149 }
150 setImageFileList(imageFileListValue ?? [])
151 setValue(desValue || '')
152 setVideoFile(videoFileValue)
153 }, [imageFileListValue, desValue, videoFileValue])
154
155 const platConfig = usePlatformInfo(platType)
156 const imageMax = useMemo(() => {
157 return imagesMaxOverride ?? platConfig?.commonPubParamsConfig.imagesMax ?? 10
158 }, [platConfig, imagesMaxOverride])
159
160 // 动态accept类型
161 const uploadAccept = useMemo(() => {
162 const hasImage = imageFileList.length !== 0
163 const hasVideo = !!videoFile
164
165 if (disableUploadCompatibilityCheck) {
166 if (hasImage && !hasVideo)
167 return 'image/*'
168 if (!hasImage && hasVideo)
169 return 'video/*'
170 return 'video/*,image/*'
171 }
172
173 if (hasImage && !hasVideo && platConfig?.pubTypes.has(PubType.ImageText))
174 return 'image/*'
175 if (!hasImage && hasVideo && platConfig?.pubTypes.has(PubType.VIDEO))
176 return 'video/*'
177
178 if (platConfig?.pubTypes.has(PubType.ImageText) && platConfig.pubTypes.has(PubType.VIDEO)) {
179 return 'video/*,image/*'
180 }
181 if (platConfig?.pubTypes.has(PubType.ImageText))
182 return 'image/*'
183 if (platConfig?.pubTypes.has(PubType.VIDEO))
184 return 'video/*'
185
186 return 'video/*,image/*'
187 }, [imageFileList, videoFile, platConfig, disableUploadCompatibilityCheck])
188
189 // 是否可见Dragger
190 const canShowDragger = useMemo(() => {
191 const imageCount = imageFileList.length
192 const videoCount = videoFile ? 1 : 0
193 const hasImage = imageCount > 0
194 const hasVideo = videoCount > 0
195
196 if (!disableUploadCompatibilityCheck && hasImage && imageCount >= imageMax)
197 return false
198 if (hasVideo && videoCount >= videoMax)
199 return false
200 // 视频和图片都没有,或者只选一种且未到上限
201 return true
202 }, [imageFileList, videoFile, imageMax, videoMax, disableUploadCompatibilityCheck])
203
204 // 检查上传文件类型
205 const checkFileListType = useCallback(
206 (fileList: File[]) => {
207 const hasImageInList = imageFileList.length !== 0
208 const hasVideoInList = !!videoFile
209 let uploadHasImage = false
210 let uploadHasVideo = false
211 let invalidFile = false
212
213 for (const file of fileList) {
214 if (file.type.startsWith('image/')) {
215 uploadHasImage = true
216 }
217 else if (file.type.startsWith('video/')) {
218 uploadHasVideo = true
219 }
220 else {
221 invalidFile = true
222 }
223 }
224
225 const messageOpen = (content: string) => {
226 toast.warning(content, { id: 'upload-warning' })
227 }
228
229 if (!disableUploadCompatibilityCheck && uploadHasImage && !platConfig?.pubTypes.has(PubType.ImageText)) {
230 messageOpen(t('validation.uploadImage'))
231 return false
232 }
233 if (!disableUploadCompatibilityCheck && uploadHasVideo && !platConfig?.pubTypes.has(PubType.VIDEO)) {
234 messageOpen(t('validation.uploadVideo'))
235 return false
236 }
237
238 // 已有图片,只能传图片
239 if (hasImageInList && !hasVideoInList && uploadHasVideo) {
240 messageOpen(t('validation.imageOnly'))
241 return false
242 }
243 // 已有视频,只能传视频
244 if (hasVideoInList && !hasImageInList && uploadHasImage) {
245 messageOpen(t('validation.videoOnly'))
246 return false
247 }
248 // 混合上传拦截
249 if (
250 (uploadHasImage && uploadHasVideo)
251 || (hasImageInList && uploadHasVideo)
252 || (hasVideoInList && uploadHasImage)
253 ) {
254 messageOpen(t('validation.imageVideoMixed'))
255 return false
256 }
257 // 非法类型
258 if (invalidFile) {
259 messageOpen(t('validation.onlyImageOrVideo'))
260 return false
261 }
262 if (uploadHasVideo) {
263 // 视频条数限制
264 const totalVideoCount
265 = (videoFile ? 1 : 0) + fileList.filter(f => f.type.startsWith('video/')).length
266 if (totalVideoCount > videoMax) {
267 messageOpen(t('validation.videoMaxExceeded', { maxCount: videoMax }))
268 return false
269 }
270 }
271 if (uploadHasImage) {
272 // 图片条数限制
273 const totalImageCount
274 = imageFileList.length + fileList.filter(f => f.type.startsWith('image/')).length
275 if (!disableUploadCompatibilityCheck && totalImageCount > imageMax) {
276 messageOpen(t('validation.imageMaxExceeded', { maxCount: imageMax }))
277 return false
278 }
279 }
280 return true
281 },
282 [imageFileList, videoMax, imageMax, videoFile, platConfig, disableUploadCompatibilityCheck, t],
283 )
284
285 const desMax = useMemo(() => {
286 return desMaxOverride ?? platConfig?.commonPubParamsConfig.desMax ?? 2200
287 }, [platConfig, desMaxOverride])
288
289 return (
290 <>
291 <VideoCoverSeting
292 videoCoverSetingModal={videoCoverSetingModal}
293 onClose={() => setVideoCoverSetingModal(false)}
294 videoFile={videoFile}
295 value={videoFile?.cover}
296 onChoosed={(newCover) => {
297 setVideoFile((prevState) => {
298 const newState = { ...(prevState as IVideoFile) }
299 newState.cover = newCover
300 return newState
301 })
302 }}
303 />
304
305 {/* 图片预览 */}
306 <MediaPreview
307 open={imagePreviewOpen && !!(previewData && (previewData as IImgFile).imgUrl)}
308 items={[
309 {
310 type: 'image',
311 src: (previewData as IImgFile)?.imgUrl || '',
312 },
313 ]}
314 onClose={() => {
315 setImagePreviewOpen(false)
316 setPreviewData(undefined)
317 }}
318 />
319
320 {/* 视频预览 */}
321 <MediaPreview
322 open={!!(previewData && (previewData as IVideoFile)?.videoUrl)}
323 items={[
324 {
325 type: 'video',
326 src: (previewData as IVideoFile)?.videoUrl,
327 },
328 ]}
329 onClose={() => setPreviewData(undefined)}
330 />
331
332 <BrushEditor
333 open={editImgIndex !== -1}
334 imageUrl={imageFileList[editImgIndex]?.imgUrl || ''}
335 onClose={() => setEditImgIndex(-1)}
336 onSave={async (newUrl, blob, meta) => {
337 const image = await formatImg({
338 blob,
339 path: meta.fileName,
340 })
341 image.ossUrl = newUrl
342 setImageFileList((prevState) => {
343 const newState = [...prevState]
344 newState[editImgIndex] = image
345 return newState
346 })
347 }}
348 />
349
350 <div className="@container border border-border rounded-md flex-1 text-left" style={style} data-testid="publish-content-editor">
351 {/* 输入区域 */}
352 <div className="p-4 relative" data-testid="publish-input-area">
353 {beforeExtend}
354 <PubParmasMentionInput
355 value={value}
356 onChange={value => setValue(value)}
357 placeholder={t('form.descriptionPlaceholder')}
358 maxLength={desMax}
359 data-testid="publish-description-input"
360 />
361
362 {/* 上传列表 */}
363 <ReactSortable
364 className={cn(
365 'grid gap-2.5',
366 isMobile
367 ? 'grid-cols-4'
368 : 'grid-cols-[repeat(auto-fill,112px)]',
369 )}
370 list={imageFileList}
371 animation={250}
372 data-testid="publish-media-list"
373 setList={(newList) => {
374 if (isFirst.current.sort) {
375 isFirst.current.sort = false
376 return
377 }
378 setImageFileList(newList)
379 }}
380 scrollSensitivity={100}
381 scrollSpeed={15}
382 id="id"
383 >
384 <TransitionGroup component={null}>
385 {/* 图像列表 */}
386 {imageFileList.map((v, i) => (
387 <CSSTransition key={v.id || v.imgUrl} timeout={300} classNames="upload-item">
388 <PubParmasTextuploadImage
389 onEditClick={() => {
390 setEditImgIndex(i)
391 }}
392 imageFile={v}
393 onClick={() => {
394 setPreviewData(v)
395 setImagePreviewOpen(true)
396 }}
397 onClose={() => {
398 const targetImage = imageFileList[i]
399 if (targetImage?.uploadTaskId) {
400 cancelUpload(targetImage.uploadTaskId)
401 }
402 setImageFileList((prevState) => {
403 const newState = [...prevState]
404 newState.splice(i, 1)
405 return newState
406 })
407 }}
408 />
409 </CSSTransition>
410 ))}
411
412 {/* 视频列表 */}
413 {(videoFile ? [videoFile] : []).map((v, i) => {
414 return (
415 <CSSTransition key={`video-${i}`} timeout={300} classNames="upload-item">
416 <div
417 className="h-[124px] w-full border border-border rounded cursor-pointer relative overflow-hidden"
418 onClick={() => {
419 setPreviewData(videoFile)
420 }}
421 >
422 {/* 关闭按钮 */}
423 <div
424 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"
425 onClick={(e) => {
426 e.stopPropagation()
427 const uploadIds = videoFile?.uploadTaskIds
428 if (uploadIds?.video) {
429 cancelUpload(uploadIds.video)
430 }
431 if (uploadIds?.cover) {
432 cancelUpload(uploadIds.cover)
433 }
434 setVideoFile(undefined)
435 }}
436 >
437 <X className="h-3 w-3" />
438 </div>
439
440 <TooltipProvider>
441 <Tooltip>
442 <TooltipTrigger asChild>
443 <div className="w-full h-full relative">
444 <img
445 src={v.cover?.imgUrl}
446 className="w-full h-full object-cover"
447 />
448 {/* 播放按钮 */}
449 <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">
450 <Play className="h-4 w-4 fill-current" />
451 </div>
452 </div>
453 </TooltipTrigger>
454 <TooltipContent>
455 <p>{t('actions.preview')}</p>
456 </TooltipContent>
457 </Tooltip>
458 </TooltipProvider>
459
460 {/* 上传进度 - 放在最后确保在最上层 */}
461 {videoFile?.uploadTaskIds?.video && (
462 <PublishUploadProgress taskId={videoFile.uploadTaskIds.video} />
463 )}
464 </div>
465 </CSSTransition>
466 )
467 })}
468
469 {/* 上传按钮 */}
470 {canShowDragger && (
471 <CSSTransition
472 key="dragger"
473 timeout={300}
474 classNames="upload-item"
475 unmountOnExit
476 >
477 <PubParmasTextareaUpload
478 checkFileListType={checkFileListType}
479 uploadAccept={uploadAccept}
480 enableGlobalDrag={true}
481 onVideoUpdateFinish={(video) => {
482 setVideoFile((prevState) => {
483 if (prevState) {
484 const prevIds = prevState.uploadTaskIds ?? {}
485 const nextIds = video?.uploadTaskIds ?? {}
486
487 if (prevIds.video && prevIds.video !== nextIds.video) {
488 cancelUpload(prevIds.video)
489 }
490
491 if (prevIds.cover && prevIds.cover !== nextIds.cover) {
492 cancelUpload(prevIds.cover)
493 }
494 }
495
496 return video
497 })
498 }}
499 onImgUpdateFinish={(imgs) => {
500 setImageFileList((prevState) => {
501 const next = [...prevState]
502
503 imgs.forEach((img) => {
504 const index = next.findIndex(item => item.id === img.id)
505
506 if (index !== -1) {
507 next[index] = img
508 }
509 else {
510 next.push(img)
511 }
512 })
513
514 return next
515 })
516 }}
517 />
518 </CSSTransition>
519 )}
520 </TransitionGroup>
521 </ReactSortable>
522
523 {/* 裁剪按钮 */}
524 {videoFile && (
525 <Button
526 variant="outline"
527 size="sm"
528 className="mt-2.5 cursor-pointer"
529 onClick={() => setVideoCoverSetingModal(true)}
530 data-testid="publish-crop-cover-button"
531 >
532 {t('actions.cropCover')}
533 </Button>
534 )}
535 </div>
536
537 {centerExtend}
538
539 {/* 底部操作栏 */}
540 <div className="border-t border-border">
541 <div className="p-2.5 px-4 relative flex items-center justify-between">
542 <div className="flex min-w-0 items-center gap-3">
543 <div
544 className="flex items-center gap-2 text-xs text-muted-foreground"
545 data-testid="publish-topic-hint"
546 >
547 <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border bg-muted px-1.5 font-medium text-foreground">
548 #
549 </span>
550 <span>{t('form.topicHint')}</span>
551 </div>
552 {toolbarExtra}
553 </div>
554 <div className="border border-muted-foreground rounded-md px-1.5 text-sm" data-testid="publish-char-counter">
555 {desMax - value.length}
556 </div>
557 </div>
558
559 {/* 扩展内容 */}
560 {extend && <div className="border-t border-border p-4">{extend}</div>}
561 </div>
562 </div>
563 </>
564 )
565 },
566 ),
567 )
568
569 export default PubParmasTextarea
570
570 lines Plain Text