| 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 |