返回 AiToEarn
usePubParamsVerify.tsx
根目录 / project / aitoearn-web / src / components / PublishDialog / hooks / usePubParamsVerify.tsx
1 import type { PlatformInfo, PlatformMediaRules } from '@/api/channels/channel.types'
2 import type { IImgFile, IVideoFile, PubItem } from '@/components/PublishDialog/publishDialog.type'
3 import { AlertTriangle } from 'lucide-react'
4 import { memo, useMemo } from 'react'
5 import { PlatType } from '@/app/config/platConfig'
6 import { PubType } from '@/app/config/publishConfig'
7 import { useTransClient } from '@/app/i18n/client'
8 import { getTwitterPublishValidationMessages } from '@/components/PublishDialog/compoents/PlatParamsSetting/plats/TwitterParams/validation'
9 import { UploadTaskStatusEnum } from '@/components/PublishDialog/compoents/PublishManageUpload/publishManageUpload.enum'
10 import { usePublishManageUpload } from '@/components/PublishDialog/compoents/PublishManageUpload/usePublishManageUpload'
11 import {
12 buildChannelPublishBody,
13 hasInvalidDescTopicFormat,
14 isAspectRatioInRange,
15 isAspectRatioMatch,
16 isPublishTitleSupported,
17 } from '@/components/PublishDialog/PublishDialog.util'
18 import { usePlatformInfoMap } from '@/hooks/usePlatformMetadata'
19 import { getFilePathName, parseTopicString } from '@/utils/common'
20 import { formatFileSize, getDurationPartsFromSeconds } from '@/utils/format'
21
22 export interface ErrPubParamsItem {
23 // 参数错误提示消息(兼容旧版,显示第一个错误)
24 parErrMsg?: string
25 // 所有错误消息列表
26 parErrMsgs?: string[]
27 // 错误状态
28 errStatus?: boolean
29 }
30
31 export type ErrPubParamsMapType = Map<string | number, ErrPubParamsItem>
32
33 const BACKEND_TOPIC_PATTERN = /#([\w\p{Script=Han}]+)/gu
34
35 const FLOW_VALIDATION_EXCLUDED_PLATFORMS = new Set<PlatType>([
36 PlatType.Xhs,
37 PlatType.WxSph,
38 ])
39
40 function normalizeTopicText(topic: string) {
41 return topic.replace(/^#+/, '').replace(/\s+/g, '')
42 }
43
44 function getTopicKey(topic: string) {
45 return normalizeTopicText(topic).toLowerCase()
46 }
47
48 function getUniqueTopics(topics: string[]) {
49 const seen = new Set<string>()
50 const uniqueTopics: string[] = []
51
52 for (const topic of topics) {
53 const normalizedTopic = normalizeTopicText(topic)
54 const topicKey = normalizedTopic.toLowerCase()
55 if (!topicKey || seen.has(topicKey))
56 continue
57
58 seen.add(topicKey)
59 uniqueTopics.push(normalizedTopic)
60 }
61
62 return uniqueTopics
63 }
64
65 function getTopicTotalLength(topics: string[]) {
66 return topics.reduce((total, topic) => total + normalizeTopicText(topic).length, 0)
67 }
68
69 function hasDuplicateTopics(topics: string[]) {
70 const seen = new Set<string>()
71 for (const topic of topics) {
72 const topicKey = getTopicKey(topic)
73 if (!topicKey)
74 continue
75 if (seen.has(topicKey))
76 return true
77 seen.add(topicKey)
78 }
79 return false
80 }
81
82 function hasOversizedImage(images: IImgFile[] | undefined, maxSize: number | undefined) {
83 if (typeof maxSize !== 'number')
84 return false
85
86 return images?.some(img => img.size > maxSize) ?? false
87 }
88
89 function isVideoSizeExceeded(video: IVideoFile | undefined, maxSize: number | undefined) {
90 if (typeof maxSize !== 'number')
91 return false
92
93 return Boolean(video && video.size > maxSize)
94 }
95
96 function isVideoDurationOutOfRange(
97 video: IVideoFile | undefined,
98 maxDuration: number | undefined,
99 minDuration: number | undefined,
100 ) {
101 if (!video)
102 return false
103
104 const isOverMax = typeof maxDuration === 'number' && video.duration > maxDuration
105 const isUnderMin = typeof minDuration === 'number' && video.duration < minDuration
106
107 return isOverMax || isUnderMin
108 }
109
110 function stripTopicsFromBody(body: string) {
111 return body
112 .replace(BACKEND_TOPIC_PATTERN, '')
113 .replace(/[ \t]+/g, ' ')
114 .replace(/[ \t]+\n/g, '\n')
115 .replace(/\n[ \t]+/g, '\n')
116 .replace(/\n{3,}/g, '\n\n')
117 .trim()
118 }
119
120 function getBodyForLength(body: string, platInfo: PlatformInfo) {
121 return platInfo.topic.nativeField ? stripTopicsFromBody(body) : body
122 }
123
124 function getStringArrayRule(mediaRules: PlatformMediaRules, key: string) {
125 const value = mediaRules[key]
126 if (!Array.isArray(value))
127 return undefined
128
129 const items = value.filter((item): item is string => typeof item === 'string')
130 return items.length > 0 ? items : undefined
131 }
132
133 function getNumberRule(mediaRules: PlatformMediaRules, key: keyof PlatformMediaRules) {
134 const value = mediaRules[key]
135 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
136 }
137
138 type TranslateFn = (key: string, options?: Record<string, number | string>) => string
139
140 function getDurationLimitLabel(seconds: number, t: TranslateFn) {
141 const durationParts = getDurationPartsFromSeconds(seconds)
142 const segments: string[] = []
143
144 if (durationParts.hours > 0) {
145 segments.push(t('validation.durationLimitHours', { value: durationParts.hours }))
146 }
147 if (durationParts.minutes > 0) {
148 segments.push(t('validation.durationLimitMinutes', { value: durationParts.minutes }))
149 }
150 if (durationParts.seconds > 0 || segments.length === 0) {
151 segments.push(t('validation.durationLimitSeconds', { value: durationParts.seconds }))
152 }
153
154 return segments.join(t('validation.durationLimitSeparator'))
155 }
156
157 function getMimeExtension(type: string | undefined) {
158 if (!type)
159 return ''
160
161 const [category, subtype] = type.toLowerCase().split('/')
162 if ((category !== 'image' && category !== 'video') || !subtype)
163 return ''
164 if (subtype === 'quicktime')
165 return 'mov'
166 return subtype.replace(/^x-/, '')
167 }
168
169 function getPathExtension(path: string | undefined) {
170 if (!path)
171 return ''
172
173 const pathWithoutQuery = path.split(/[?#]/)[0]
174 const { filename, suffix } = getFilePathName(pathWithoutQuery)
175 if (!filename.includes('.'))
176 return ''
177 return suffix.toLowerCase()
178 }
179
180 function getFirstPathExtension(paths: Array<string | undefined>) {
181 for (const path of paths) {
182 const extension = getPathExtension(path)
183 if (extension)
184 return extension
185 }
186 return ''
187 }
188
189 function getImageExtension(image: IImgFile) {
190 return getMimeExtension(image.file?.type) || getFirstPathExtension([
191 image.filename,
192 image.imgPath,
193 image.ossUrl,
194 image.imgUrl,
195 ])
196 }
197
198 function getVideoExtension(video: IVideoFile) {
199 return getMimeExtension(video.file?.type) || getFirstPathExtension([
200 video.filename,
201 video.ossUrl,
202 video.videoUrl,
203 ])
204 }
205
206 function hasUnsupportedImageFormat(images: IImgFile[] | undefined, allowedFormats: string[] | undefined) {
207 if (!images?.length || !allowedFormats?.length)
208 return false
209
210 const allowed = new Set(allowedFormats.map(format => format.toLowerCase()))
211 return images.some((image) => {
212 const extension = getImageExtension(image)
213 return Boolean(extension) && !allowed.has(extension)
214 })
215 }
216
217 function hasUnsupportedVideoFormat(video: IVideoFile | undefined, allowedFormats: string[] | undefined) {
218 if (!video || !allowedFormats?.length)
219 return false
220
221 const extension = getVideoExtension(video)
222 if (!extension)
223 return false
224
225 return !allowedFormats.map(format => format.toLowerCase()).includes(extension)
226 }
227
228 function hasTextContent(params: PubItem['params'], bodyForLength: string, titleSupported: boolean) {
229 return Boolean(bodyForLength || (titleSupported && params.title))
230 }
231
232 function getFacebookAllowedFormats(contentCategory: string | undefined) {
233 if (contentCategory === 'reel') {
234 return { videoFormats: ['mp4'] }
235 }
236 if (contentCategory === 'story') {
237 return {
238 imageFormats: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'],
239 videoFormats: ['mp4', 'mov'],
240 }
241 }
242 return {
243 imageFormats: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'],
244 videoFormats: ['mp4', 'mov', 'avi'],
245 }
246 }
247
248 function getInstagramMentionCount(body: string) {
249 return body.match(/(^|[^\w.])@([\w.]+)/g)?.length ?? 0
250 }
251
252 /**
253 * 发布参数校验是否复合平台规范
254 * @param data
255 */
256 export default function usePubParamsVerify(data: PubItem[]) {
257 console.log(data)
258 const { t } = useTransClient('publish', { useSuspense: false })
259 const platformInfoMap = usePlatformInfoMap()
260
261 const tasks = usePublishManageUpload(state => state.tasks)
262
263 // 错误参数,发布之前会检测错误参数,防止平台无法发布
264 const errParamsMap = useMemo(() => {
265 const errParamsMapTemp: ErrPubParamsMapType = new Map()
266 for (const v of data) {
267 const platInfo = platformInfoMap.get(v.account.type)
268 if (!platInfo)
269 continue
270 const { topics } = parseTopicString(v.params.des || '')
271 const paramsTopics = v.params.topics ?? []
272 const topicsAll = getUniqueTopics(paramsTopics.concat(topics))
273 const { titleMax, topicMax, topicMaxTotalLength } = platInfo.commonPubParamsConfig
274 const video = v.params.video
275 const publishBody = buildChannelPublishBody(v.params.des, v.params.topics)
276 const bodyForLength = getBodyForLength(publishBody, platInfo)
277 const titleSupported = isPublishTitleSupported(v.account.type)
278 const textSupported = platInfo.pubTypes.has(PubType.Article)
279 const imageTextSupported = platInfo.pubTypes.has(PubType.ImageText)
280 const videoSupported = platInfo.pubTypes.has(PubType.VIDEO)
281 const images = v.params.images ?? []
282 const shouldValidateFlowRules = !FLOW_VALIDATION_EXCLUDED_PLATFORMS.has(v.account.type)
283
284 // 收集当前账号的所有错误
285 const errors: string[] = []
286 const addErrorMsg = (msg: string) => {
287 if (errors.includes(msg))
288 return
289 errors.push(msg)
290 }
291
292 // ------------------------ 通用参数校验 ------------------------
293
294 // 媒体上传完成校验(阻止在上传未完成时发布)
295 const hasImages = (v.params.images?.length || 0) > 0
296 const hasVideo = Boolean(v.params.video)
297
298 const isImageUploaded = (img: IImgFile) => {
299 return (
300 !!img.ossUrl
301 || (img.uploadTaskId && tasks[img.uploadTaskId]?.status === UploadTaskStatusEnum.Success)
302 )
303 }
304
305 const isVideoUploaded = (vd: IVideoFile | undefined) => {
306 const videoOk
307 = !!vd?.ossUrl
308 || (vd?.uploadTaskIds?.video
309 && tasks[vd.uploadTaskIds.video]?.status === UploadTaskStatusEnum.Success)
310
311 const hasCoverUploadTask = Boolean(vd?.cover?.uploadTaskId || vd?.uploadTaskIds?.cover)
312 const coverOk
313 = !hasCoverUploadTask
314 || !!vd?.cover?.ossUrl
315 || (vd?.uploadTaskIds?.cover
316 && tasks[vd.uploadTaskIds.cover]?.status === UploadTaskStatusEnum.Success)
317 return videoOk && coverOk
318 }
319
320 if (hasImages) {
321 const notFinished = (v.params.images || []).some(img => !isImageUploaded(img))
322 if (notFinished) {
323 addErrorMsg(t('upload.finishingUp'))
324 }
325 }
326
327 if (hasVideo) {
328 if (!isVideoUploaded(v.params.video)) {
329 addErrorMsg(t('upload.finishingUp'))
330 }
331 }
332
333 if (hasImages && hasVideo) {
334 addErrorMsg(t('validation.imageVideoMixed'))
335 }
336
337 if (shouldValidateFlowRules && hasImages && !imageTextSupported) {
338 addErrorMsg(t('validation.imageContentUnsupported', { platformName: platInfo.name }))
339 }
340
341 if (shouldValidateFlowRules && hasVideo && !videoSupported) {
342 addErrorMsg(t('validation.videoContentUnsupported', { platformName: platInfo.name }))
343 }
344
345 if (shouldValidateFlowRules && !hasImages && !hasVideo && hasTextContent(v.params, bodyForLength, titleSupported) && !textSupported) {
346 addErrorMsg(t('validation.textContentUnsupported', { platformName: platInfo.name }))
347 }
348
349 const maxTotalTextLength = platInfo.contentLimits.maxTotalTextLength
350 if (shouldValidateFlowRules && typeof maxTotalTextLength === 'number') {
351 const totalTextLength = (titleSupported ? (v.params.title?.length ?? 0) : 0) + bodyForLength.length
352 if (totalTextLength > maxTotalTextLength) {
353 addErrorMsg(
354 t('validation.totalTextMaxExceeded', {
355 platformName: platInfo.name,
356 maxCount: maxTotalTextLength,
357 }),
358 )
359 }
360 }
361
362 const imageFormats = getStringArrayRule(platInfo.mediaRules, 'imageFormats')
363 const videoFormats = getStringArrayRule(platInfo.mediaRules, 'videoFormats')
364 const imageMaxSize = getNumberRule(platInfo.mediaRules, 'maxImageSize')
365 const videoMaxSize = getNumberRule(platInfo.mediaRules, 'maxVideoSize')
366 const videoMinDuration = getNumberRule(platInfo.mediaRules, 'minVideoDuration')
367 const videoMaxDuration = getNumberRule(platInfo.mediaRules, 'maxVideoDuration')
368
369 if (shouldValidateFlowRules && hasUnsupportedImageFormat(v.params.images, imageFormats)) {
370 addErrorMsg(t('validation.imageFormatUnsupported', { formats: imageFormats?.join(', ') ?? '' }))
371 }
372 if (shouldValidateFlowRules && hasUnsupportedVideoFormat(video, videoFormats)) {
373 addErrorMsg(t('validation.videoFormatUnsupported', { formats: videoFormats?.join(', ') ?? '' }))
374 }
375 if (typeof imageMaxSize === 'number' && hasOversizedImage(v.params.images, imageMaxSize)) {
376 addErrorMsg(t('validation.imageSizeExceeded', {
377 platformName: platInfo.name,
378 maxSize: formatFileSize(imageMaxSize),
379 }))
380 }
381 if (typeof videoMaxSize === 'number' && isVideoSizeExceeded(video, videoMaxSize)) {
382 addErrorMsg(t('validation.videoSizeExceeded', {
383 platformName: platInfo.name,
384 maxSize: formatFileSize(videoMaxSize),
385 }))
386 }
387 if (isVideoDurationOutOfRange(video, videoMaxDuration, videoMinDuration)) {
388 addErrorMsg(
389 typeof videoMinDuration === 'number' && typeof videoMaxDuration === 'number'
390 ? t('validation.videoDurationRangeExceeded', {
391 platformName: platInfo.name,
392 minDuration: getDurationLimitLabel(videoMinDuration, t),
393 maxDuration: getDurationLimitLabel(videoMaxDuration, t),
394 })
395 : t('validation.videoDurationMaxExceeded', {
396 platformName: platInfo.name,
397 maxDuration: getDurationLimitLabel(videoMaxDuration ?? videoMinDuration ?? 0, t),
398 }),
399 )
400 }
401
402 // 描述校验
403 if (
404 (v.account.type === PlatType.Threads
405 || v.account.type === PlatType.Twitter
406 || v.account.type === PlatType.KWAI)
407 && !v.params.des
408 ) {
409 addErrorMsg(t('validation.descriptionRequired'))
410 }
411
412 // 标题字数校验
413 if (isPublishTitleSupported(v.account.type) && titleMax !== undefined && v.params.title && v.params.title.length > titleMax) {
414 addErrorMsg(
415 t('validation.titleMaxExceeded', {
416 platformName: platInfo.name,
417 maxCount: titleMax,
418 }),
419 )
420 }
421
422 // 描述字数校验
423 if (bodyForLength && bodyForLength.length > platInfo.commonPubParamsConfig.desMax) {
424 addErrorMsg(
425 t('validation.descriptionMaxExceeded', {
426 platformName: platInfo.name,
427 maxCount: platInfo.commonPubParamsConfig.desMax,
428 }),
429 )
430 }
431
432 // 图片数量校验
433 if (
434 platInfo.pubTypes.has(PubType.ImageText)
435 && images.length > 1
436 && images.length > platInfo.commonPubParamsConfig.imagesMax!
437 ) {
438 addErrorMsg(
439 t('validation.imageMaxExceeded', {
440 platformName: platInfo.name,
441 maxCount: platInfo.commonPubParamsConfig.imagesMax,
442 }),
443 )
444 }
445
446 // 图片或者视频校验,视频和图片必须要上传一个
447 if (
448 !platInfo.pubTypes.has(PubType.Article)
449 && !hasImages
450 && !v.params.video
451 ) {
452 let msgs = t('validation.uploadImageOrVideo')
453 if (platInfo.pubTypes.has(PubType.ImageText) && platInfo.pubTypes.has(PubType.VIDEO)) {
454 msgs = t('validation.uploadImageOrVideo')
455 }
456 else if (platInfo.pubTypes.has(PubType.ImageText)) {
457 msgs = t('validation.uploadImage')
458 }
459 else if (platInfo.pubTypes.has(PubType.VIDEO)) {
460 msgs = t('validation.uploadVideo')
461 }
462 addErrorMsg(msgs)
463 }
464
465 // 话题支持与数量校验:maxCount 缺省表示不限制,supported=false 表示不支持话题
466 if (!platInfo.topic.supported && topicsAll.length > 0) {
467 addErrorMsg(
468 t('validation.topicUnsupported', {
469 platformName: platInfo.name,
470 }),
471 )
472 }
473
474 if (platInfo.topic.supported && topicMax !== undefined && topicsAll.length > topicMax) {
475 addErrorMsg(
476 t('validation.topicMaxExceeded', {
477 platformName: platInfo.name,
478 maxCount: topicMax,
479 }),
480 )
481 }
482
483 if (hasDuplicateTopics(paramsTopics) || hasDuplicateTopics(topics)) {
484 addErrorMsg(t('validation.topicDuplicate'))
485 }
486
487 if (
488 platInfo.topic.supported
489 && topicMaxTotalLength !== undefined
490 && getTopicTotalLength(topicsAll) > topicMaxTotalLength
491 ) {
492 addErrorMsg(
493 t('validation.topicTotalLengthExceeded', {
494 platformName: platInfo.name,
495 max: topicMaxTotalLength,
496 }),
497 )
498 }
499
500 // 判断描述中的话题格式是否正确,如:#话题1#话题2 或 # 话题 这种格式错误
501 if (hasInvalidDescTopicFormat(v.params.des || '')) {
502 addErrorMsg(t('validation.topicFormatError'))
503 }
504
505 // ------------------------ 单个平台参数校验 ------------------------
506
507 // b站的强制校验
508 if (v.account.type === PlatType.BILIBILI) {
509 if (!v.params.title) {
510 addErrorMsg(t('validation.titleRequired'))
511 }
512 if (topicsAll.length === 0) {
513 addErrorMsg(t('validation.topicRequired'))
514 }
515 if (!v.params.option.bilibili?.tid) {
516 addErrorMsg(t('validation.partitionRequired'))
517 }
518 if (v.params.option.bilibili?.copyright === 2 && !v.params.option.bilibili.source) {
519 addErrorMsg(t('validation.sourceRequired'))
520 }
521 }
522
523 // facebook的强制校验
524 if (v.account.type === PlatType.Facebook) {
525 const contentCategory = v.params.option.facebook?.content_category
526 const facebookFormats = getFacebookAllowedFormats(contentCategory)
527 if (hasUnsupportedImageFormat(v.params.images, facebookFormats.imageFormats)) {
528 addErrorMsg(t('validation.imageFormatUnsupported', { formats: facebookFormats.imageFormats?.join(', ') ?? '' }))
529 }
530 if (hasUnsupportedVideoFormat(video, facebookFormats.videoFormats)) {
531 addErrorMsg(t('validation.videoFormatUnsupported', { formats: facebookFormats.videoFormats?.join(', ') ?? '' }))
532 }
533
534 switch (v.params.option.facebook?.content_category) {
535 case 'post':
536 break
537 case 'reel':
538 if (!video) {
539 addErrorMsg(t('validation.uploadVideo'))
540 }
541 if ((v.params.images?.length || 0) !== 0) {
542 addErrorMsg(t('validation.facebookReelNoImage'))
543 }
544 break
545 case 'story':
546 if (!hasImages && !video) {
547 addErrorMsg(t('validation.uploadImageOrVideo'))
548 }
549 if ((v.params.images?.length || 0) + (video ? 1 : 0) > 1) {
550 addErrorMsg(t('validation.facebookStoryMediaMax'))
551 }
552 // facebook story 只能选择图片、视频,不能有描述
553 if (v.params.des) {
554 addErrorMsg(t('validation.facebookStoryNoDes'))
555 }
556 if (v.params.title) {
557 addErrorMsg(t('validation.facebookStoryNoTitle'))
558 }
559 if (v.params.option.facebook?.link) {
560 addErrorMsg(t('validation.facebookStoryNoLink'))
561 }
562 break
563 }
564 }
565
566 // instagram的强制校验
567 if (v.account.type === PlatType.Instagram) {
568 if (getInstagramMentionCount(publishBody) > 20) {
569 addErrorMsg(t('validation.instagramMentionMax'))
570 }
571
572 // 图片比例判断
573 if (
574 v.params.option.instagram?.content_category === 'post'
575 && v.params.images
576 && v.params.images.length > 0
577 ) {
578 for (const img of v.params.images) {
579 // Instagram Post 图片比例范围:4:5 ~ 1.91:1 (0.8 ~ 1.91)
580 if (!isAspectRatioInRange(img.width, img.height, 4 / 5, 1.91)) {
581 addErrorMsg(t('validation.instagramImageValidation'))
582 break
583 }
584 }
585 }
586
587 switch (v.params.option.instagram?.content_category) {
588 case 'post':
589 // instagram post不能上传视频,必须上传图片
590 if (video) {
591 addErrorMsg(t('validation.instagramPostNoVideo'))
592 }
593 break
594 case 'reel':
595 if (!video) {
596 addErrorMsg(t('validation.uploadVideo'))
597 }
598 // instagram reel 不能上传图片,必须上传视频 1
599 if ((v.params.images?.length || 0) !== 0) {
600 addErrorMsg(t('validation.instagramReelNoImage'))
601 }
602 // instagram reel 视频宽高比限制:4:5 ~ 9:16 (0.8 ~ 0.5625)
603 if (video && !isAspectRatioInRange(video.width, video.height, 9 / 16, 4 / 5)) {
604 addErrorMsg(t('validation.instagramReelAspectRatio'))
605 }
606 break
607 case 'story':
608 // instagram story 只能选择图片/视频,不能有描述
609 if (v.params.des) {
610 addErrorMsg(t('validation.instagramStoryNoDes'))
611 }
612 break
613 }
614 }
615
616 if (v.account.type === PlatType.Threads) {
617 if (!hasImages && !video && !bodyForLength) {
618 addErrorMsg(t('validation.descriptionRequired'))
619 }
620 }
621
622 // Pinterest 的强制校验
623 if (v.account.type === PlatType.Pinterest) {
624 // 强制需要标题
625 if (!v.params.title) {
626 addErrorMsg(t('validation.titleRequired'))
627 }
628 // 强制需要 选择Board
629 if (!v.params.option.pinterest?.boardId) {
630 addErrorMsg(t('validation.boardRequired'))
631 }
632 if (video && !v.params.video?.cover.ossUrl && !v.params.option.pinterest?.coverImageUrl) {
633 addErrorMsg(t('validation.coverRequired'))
634 }
635 }
636
637 // YouTube 的强制校验
638 if (v.account.type === PlatType.YouTube) {
639 // 强制需要标题
640 if (!v.params.title) {
641 addErrorMsg(t('validation.titleRequired'))
642 }
643 // 强制需要描述
644 if (!v.params.des) {
645 addErrorMsg(t('validation.descriptionRequired'))
646 }
647 // 强制需要选择视频分类
648 if (!v.params.option.youtube?.categoryId) {
649 addErrorMsg(t('validation.categoryRequired'))
650 }
651 }
652
653 // TikTok 的强制校验
654 if (v.account.type === PlatType.Tiktok) {
655 if ((v.params.images?.length || 0) === 1) {
656 addErrorMsg(t('validation.tiktokImageMin'))
657 }
658 // TikTok 图片最小高度和宽度为 360 像素
659 if (v.params.images) {
660 for (const img of v.params.images) {
661 if (img.width < 360 || img.height < 360) {
662 addErrorMsg(t('validation.tiktokImageMinResolution'))
663 break
664 }
665 }
666 }
667 // TikTok 内容披露校验:开启披露但未选择任何选项
668 const tiktokOption = v.params.option.tiktok
669 // TikTok 隐私级别必填校验
670 if (!tiktokOption?.privacy_level) {
671 addErrorMsg(t('validation.tiktokPrivacyLevelRequired'))
672 }
673 if (
674 tiktokOption?.brand_disclosure_enabled === true
675 && !tiktokOption?.brand_organic_toggle
676 && !tiktokOption?.brand_content_toggle
677 ) {
678 addErrorMsg(t('validation.tiktokContentDisclosureRequired'))
679 }
680 }
681
682 // Twitter 的强制校验
683 if (v.account.type === PlatType.Twitter) {
684 getTwitterPublishValidationMessages(v.params, t).forEach(addErrorMsg)
685 }
686
687 // 如果有错误,保存到 Map 中
688 if (errors.length > 0) {
689 errParamsMapTemp.set(v.account.id, {
690 parErrMsg: errors[0], // 兼容旧版,显示第一个错误
691 parErrMsgs: errors, // 所有错误
692 })
693 }
694 }
695 return errParamsMapTemp
696 }, [data, platformInfoMap, t, tasks])
697
698 // 警告参数,警告参数不会阻止发布,只是提示用户可能存在的问题
699 const warningParamsMap = useMemo(() => {
700 const warningParamsMapTemp: ErrPubParamsMapType = new Map()
701
702 for (const v of data) {
703 // 收集当前账号的所有警告
704 const warnings: string[] = []
705 const addWarningMsg = (msg: string) => {
706 warnings.push(msg)
707 }
708
709 // YouTube 警告消息
710 if (v.account.type === PlatType.YouTube) {
711 // 建议分辨率:1920×1080(16:9)或 1080×1920(9:16)。
712 if (v.params.video) {
713 const video = v.params.video
714 if (
715 !isAspectRatioMatch(video.width, video.height, 16 / 9)
716 && !isAspectRatioMatch(video.width, video.height, 9 / 16)
717 ) {
718 addWarningMsg(t('validation.youtubeResolutionSuggestion'))
719 }
720 }
721 }
722
723 // 快手 警告消息
724 if (v.account.type === PlatType.KWAI) {
725 // 推荐分辨率:1080x1920(竖屏)
726 if (v.params.video) {
727 const video = v.params.video
728 if (!isAspectRatioMatch(video.width, video.height, 9 / 16)) {
729 addWarningMsg(t('validation.kwaiResolutionSuggestion'))
730 }
731 }
732 // 时长建议:15 秒 - 3 分钟
733 if (v.params.video) {
734 const video = v.params.video
735 if (video.duration < 15 || video.duration > 180) {
736 addWarningMsg(t('validation.kwaiDurationSuggestion'))
737 }
738 }
739 }
740
741 // 如果有警告,保存到 Map 中
742 if (warnings.length > 0) {
743 warningParamsMapTemp.set(v.account.id, {
744 parErrMsg: warnings[0], // 兼容旧版,显示第一个警告
745 parErrMsgs: warnings, // 所有警告
746 })
747 }
748 }
749 return warningParamsMapTemp
750 }, [data, t])
751
752 return {
753 errParamsMap,
754 warningParamsMap,
755 }
756 }
757
758 // 用于展示校验的结果
759 export const PubParamsVerifyInfo = memo(({ errItem }: { errItem?: ErrPubParamsItem }) => {
760 return (
761 <>
762 {errItem && (
763 <div className="flex items-start gap-2 mb-4 p-2 rounded-md bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 text-xs">
764 <AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
765 <p className="text-left text-yellow-800 dark:text-yellow-200">{errItem.parErrMsg}</p>
766 </div>
767 )}
768 </>
769 )
770 })
771
771 lines Plain Text