返回 AiToEarn
usePublishDialogStorageStore.tsx
根目录 / project / aitoearn-web / src / components / PublishDialog / usePublishDialogStorageStore.tsx
1 import type { IImgFile, IVideoFile, PubItem } from '@/components/PublishDialog/publishDialog.type'
2 import { directTrans } from '@/app/i18n/client'
3 import { usePublishManageUpload } from '@/components/PublishDialog/compoents/PublishManageUpload/usePublishManageUpload'
4 import { isPublishTitleSupported, isSameSocialAccount } from '@/components/PublishDialog/PublishDialog.util'
5 import { usePublishDialog } from '@/components/PublishDialog/usePublishDialog'
6 import { useAccountStore } from '@/store/account'
7 import { isPlatformEnabledSync, isPlatformMetadataReadySync } from '@/store/platformMetadata'
8 import { getOssUrl } from '@/utils/oss'
9 import { createPersistStore } from '@/utils/storage/createPersistStore'
10 import { confirm } from '@/utils/ui/confirm'
11
12 const RESTORE_CONFIRM_Z_INDEX = 10000
13
14 export interface IPublishDialogStorageStore {
15 pubListChoosed?: PubItem[]
16 expandedPubItem?: PubItem
17 pubList?: PubItem[]
18 desktopLayout?: PublishDialogDesktopLayout
19 isDesktopDraftPanelOpen?: boolean
20 }
21
22 export interface PublishDialogDesktopLayout {
23 draftPanelWidth: number
24 publishPanelWidth: number
25 }
26
27 const state: IPublishDialogStorageStore = {
28 // 实时保存的发布数据
29 pubListChoosed: undefined,
30 // 保存当前展开的数据
31 expandedPubItem: undefined,
32 // 实时保存的 所有发布列表
33 pubList: undefined,
34 // PC 端发布弹框双栏布局宽度
35 desktopLayout: undefined,
36 // PC 端发布弹框左侧 AI 内容创作栏是否展开
37 isDesktopDraftPanelOpen: undefined,
38 }
39
40 const VIDEO_URL_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.m4v', '.avi', '.mkv'])
41
42 function getRestorableMediaUrl(url?: string) {
43 const mediaUrl = url?.trim()
44 if (!mediaUrl || mediaUrl.startsWith('blob:') || mediaUrl.startsWith('data:'))
45 return undefined
46
47 return mediaUrl
48 }
49
50 function getUploadedUrlFromTask(taskId?: string) {
51 if (!taskId)
52 return undefined
53
54 const { tasks, md5Cache } = usePublishManageUpload.getState()
55 const md5 = tasks[taskId]?.md5
56 if (!md5)
57 return undefined
58
59 return md5Cache[md5]?.ossUrl
60 }
61
62 function getDisplayMediaUrl(ossUrl: string) {
63 return getOssUrl(ossUrl) || ossUrl
64 }
65
66 function getMediaUrlExtension(url: string) {
67 try {
68 const parsedUrl = new URL(getDisplayMediaUrl(url), 'https://aitoearn.local')
69 const extensionIndex = parsedUrl.pathname.lastIndexOf('.')
70 return extensionIndex === -1 ? '' : parsedUrl.pathname.slice(extensionIndex).toLowerCase()
71 }
72 catch {
73 return ''
74 }
75 }
76
77 function isProbablyVideoUrl(url: string) {
78 return VIDEO_URL_EXTENSIONS.has(getMediaUrlExtension(url))
79 }
80
81 function isSameMediaUrl(prevUrl: string | undefined, nextUrl: string | undefined) {
82 if (!prevUrl || !nextUrl)
83 return false
84
85 return prevUrl === nextUrl || getDisplayMediaUrl(prevUrl) === getDisplayMediaUrl(nextUrl)
86 }
87
88 function normalizeRestoredImage(image: IImgFile, uploadTaskId = image.uploadTaskId): IImgFile | null {
89 const ossUrl = getRestorableMediaUrl(image.ossUrl)
90 ?? getRestorableMediaUrl(getUploadedUrlFromTask(uploadTaskId))
91 ?? getRestorableMediaUrl(image.imgUrl)
92
93 if (!ossUrl)
94 return null
95
96 const restoredImage: IImgFile = {
97 ...image,
98 ossUrl,
99 imgUrl: getDisplayMediaUrl(ossUrl),
100 }
101 delete restoredImage.uploadTaskId
102
103 return restoredImage
104 }
105
106 function getRestorableCoverUrl(video: IVideoFile, videoOssUrl: string) {
107 const coverUrl = getRestorableMediaUrl(video.cover?.ossUrl)
108 ?? getRestorableMediaUrl(getUploadedUrlFromTask(video.uploadTaskIds?.cover))
109 if (coverUrl)
110 return coverUrl
111
112 const previewUrl = getRestorableMediaUrl(video.cover?.imgUrl)
113 if (
114 !previewUrl
115 || isSameMediaUrl(previewUrl, videoOssUrl)
116 || isSameMediaUrl(previewUrl, video.videoUrl)
117 || isProbablyVideoUrl(previewUrl)
118 ) {
119 return undefined
120 }
121
122 return previewUrl
123 }
124
125 function normalizeRestoredCover(video: IVideoFile, videoOssUrl: string): IImgFile {
126 const coverUrl = getRestorableCoverUrl(video, videoOssUrl)
127 if (!coverUrl)
128 return createFallbackCover(video, videoOssUrl)
129
130 const originalCover = video.cover
131
132 const restoredCover: IImgFile = {
133 ...(originalCover ?? {}),
134 id: originalCover?.id || `${video.filename || videoOssUrl}-cover`,
135 size: originalCover?.size || 0,
136 file: originalCover?.file || new File([], originalCover?.filename || `${video.filename || 'video'}-cover.jpg`),
137 imgUrl: getDisplayMediaUrl(coverUrl),
138 ossUrl: coverUrl,
139 filename: originalCover?.filename || `${video.filename || 'video'}-cover.jpg`,
140 imgPath: originalCover?.imgPath || '',
141 width: originalCover?.width || video.width,
142 height: originalCover?.height || video.height,
143 }
144 delete restoredCover.uploadTaskId
145
146 return restoredCover
147 }
148
149 function createFallbackCover(video: IVideoFile, videoOssUrl: string): IImgFile {
150 const filename = video.cover?.filename || `${video.filename || 'video'}-cover.jpg`
151 const coverImgUrl = getRestorableMediaUrl(video.cover?.imgUrl)
152 const imgUrl = coverImgUrl
153 && !isSameMediaUrl(coverImgUrl, videoOssUrl)
154 && !isSameMediaUrl(coverImgUrl, video.videoUrl)
155 && !isProbablyVideoUrl(coverImgUrl)
156 ? getDisplayMediaUrl(coverImgUrl)
157 : ''
158
159 return {
160 id: video.cover?.id || `${video.filename || video.videoUrl}-cover`,
161 size: video.cover?.size || 0,
162 file: video.cover?.file || new File([], filename),
163 imgUrl,
164 filename,
165 imgPath: video.cover?.imgPath || '',
166 width: video.cover?.width || video.width,
167 height: video.cover?.height || video.height,
168 }
169 }
170
171 function normalizeRestoredVideo(video?: IVideoFile): IVideoFile | undefined {
172 if (!video)
173 return undefined
174
175 const ossUrl = getRestorableMediaUrl(video.ossUrl)
176 ?? getRestorableMediaUrl(getUploadedUrlFromTask(video.uploadTaskIds?.video))
177 ?? getRestorableMediaUrl(video.videoUrl)
178
179 if (!ossUrl)
180 return undefined
181
182 const cover = normalizeRestoredCover(video, ossUrl)
183 const restoredVideo: IVideoFile = {
184 ...video,
185 ossUrl,
186 videoUrl: getDisplayMediaUrl(ossUrl),
187 cover,
188 }
189 delete restoredVideo.uploadTaskIds
190
191 return restoredVideo
192 }
193
194 function mergeRestoredPubList(restoredPubList: PubItem[] | undefined, restoredChoosed: PubItem[]) {
195 const currentPubList = usePublishDialog.getState().pubList
196 const restoredPubListMap = new Map((restoredPubList ?? []).map(pubItem => [pubItem.account.id, pubItem]))
197 const restoredChoosedMap = new Map(restoredChoosed.map(pubItem => [pubItem.account.id, pubItem]))
198 const basePubList = currentPubList.length > 0 ? currentPubList : restoredPubList ?? []
199 const mergedPubList = basePubList.map(pubItem => (
200 restoredChoosedMap.get(pubItem.account.id)
201 ?? restoredPubListMap.get(pubItem.account.id)
202 ?? pubItem
203 ))
204 const mergedAccountIds = new Set(mergedPubList.map(pubItem => pubItem.account.id))
205
206 restoredPubList?.forEach((pubItem) => {
207 if (!mergedAccountIds.has(pubItem.account.id)) {
208 mergedPubList.push(pubItem)
209 mergedAccountIds.add(pubItem.account.id)
210 }
211 })
212
213 restoredChoosed.forEach((pubItem) => {
214 if (!mergedAccountIds.has(pubItem.account.id)) {
215 mergedPubList.push(pubItem)
216 mergedAccountIds.add(pubItem.account.id)
217 }
218 })
219
220 return mergedPubList
221 }
222
223 function isPubItem(value: PubItem | null): value is PubItem {
224 return Boolean(value)
225 }
226
227 function canUsePlatform(platType: PubItem['account']['type']) {
228 if (!isPlatformMetadataReadySync())
229 return true
230
231 return isPlatformEnabledSync(platType)
232 }
233
234 function getLatestAccount(pubItem: PubItem) {
235 const accountMap = useAccountStore.getState().accountMap
236 return accountMap.get(pubItem.account.id)
237 ?? Array.from(accountMap.values()).find(account => isSameSocialAccount(pubItem.account, account))
238 }
239
240 export const usePublishDialogStorageStore = createPersistStore(
241 {
242 ...state,
243 },
244 (set, _get) => {
245 /**
246 * 检查发布项是否有有效内容(视频、图片或描述)
247 * @param pubItem 发布项
248 * @returns 是否有有效内容
249 */
250 const hasValidContent = (pubItem: PubItem): boolean => {
251 const { params } = pubItem
252 // 检查是否有视频
253 const hasVideo = !!params.video
254 // 检查是否有图片
255 const hasImages = !!(params.images && params.images.length > 0)
256 // 检查是否有描述(非空字符串)
257 const hasDescription = !!(params.des && params.des.trim().length > 0)
258
259 return hasVideo || hasImages || hasDescription
260 }
261
262 const methods = {
263 setExpandedPubItem(expandedPubItem: PubItem | undefined) {
264 set({
265 expandedPubItem,
266 })
267 },
268 setPubData(pubListChoosed: PubItem[] | undefined) {
269 // 过滤掉没有有效内容的项
270 const filteredList = pubListChoosed?.filter(pubItem => hasValidContent(pubItem) && canUsePlatform(pubItem.account.type))
271 // 如果过滤后为空数组,则不存储
272 if (!filteredList || filteredList.length === 0) {
273 set({ pubListChoosed: undefined })
274 return
275 }
276 set({ pubListChoosed: filteredList })
277 },
278 setPubListData(pubList?: PubItem[]) {
279 // 过滤掉没有有效内容的项
280 const filteredList = pubList?.filter(pubItem => hasValidContent(pubItem) && canUsePlatform(pubItem.account.type))
281 // 如果过滤后为空数组,则不存储
282 if (!filteredList || filteredList.length === 0) {
283 set({ pubList: undefined })
284 return
285 }
286 set({ pubList: filteredList })
287 },
288
289 clearPubData() {
290 set({ pubListChoosed: undefined, expandedPubItem: undefined, pubList: undefined })
291 },
292
293 setDesktopLayout(desktopLayout: PublishDialogDesktopLayout) {
294 set({ desktopLayout })
295 },
296
297 setDesktopDraftPanelOpen(isDesktopDraftPanelOpen: boolean) {
298 set({ isDesktopDraftPanelOpen })
299 },
300
301 // 恢复发布记录
302 restorePubData() {
303 let { pubListChoosed, expandedPubItem, pubList } = _get()
304
305 if (pubListChoosed === undefined || pubListChoosed.length === 0) {
306 return
307 }
308 // 提示用户是否恢复
309 confirm({
310 title: directTrans('publish', 'restoreData.title'),
311 content: directTrans('publish', 'restoreData.content'),
312 okText: directTrans('publish', 'restoreData.okText'),
313 cancelText: directTrans('publish', 'restoreData.cancelText'),
314 zIndex: RESTORE_CONFIRM_Z_INDEX,
315 async onOk() {
316 // 处理已选择的发布列表
317 pubListChoosed = pubListChoosed
318 ?.map(v => methods.processPubItem(v))
319 .filter(isPubItem)
320
321 if (!pubListChoosed || pubListChoosed.length === 0) {
322 methods.clearPubData()
323 return
324 }
325
326 // 处理所有发布列表
327 let restoredPubList: PubItem[] | undefined
328 if (pubList && pubList.length > 0) {
329 restoredPubList = pubList.map(v => methods.processPubItem(v)).filter(isPubItem)
330 }
331
332 const mergedPubList = mergeRestoredPubList(restoredPubList, pubListChoosed)
333 const expandedAccountId = expandedPubItem?.account.id
334 const nextExpandedPubItem = expandedAccountId
335 ? pubListChoosed.find(v => v.account.id === expandedAccountId) ?? pubListChoosed[0]
336 : pubListChoosed[0]
337
338 // 恢复数据
339 const publishDialogStore = usePublishDialog.getState()
340 publishDialogStore.setPubList(mergedPubList)
341 publishDialogStore.setPubListChoosed(pubListChoosed)
342 publishDialogStore.setStep(1)
343 publishDialogStore.setExpandedPubItem(nextExpandedPubItem)
344 },
345 onCancel() {
346 methods.clearPubData()
347 },
348 })
349 },
350
351 /**
352 * 处理单个发布项:更新账户信息、过滤无效媒体资源
353 * @param pubItem 发布项
354 * @returns 处理后的发布项,如果账户不存在则返回null
355 */
356 processPubItem(pubItem: PubItem): PubItem | null {
357 const account = getLatestAccount(pubItem)
358
359 // 更新账户信息
360 if (account && canUsePlatform(account.type)) {
361 const nextParams = {
362 ...pubItem.params,
363 images: pubItem.params.images
364 ?.map(img => normalizeRestoredImage(img))
365 .filter((img): img is IImgFile => Boolean(img)),
366 video: normalizeRestoredVideo(pubItem.params.video),
367 }
368
369 if (!isPublishTitleSupported(account.type))
370 delete nextParams.title
371
372 const nextPubItem = {
373 ...pubItem,
374 account,
375 params: nextParams,
376 }
377
378 return hasValidContent(nextPubItem) ? nextPubItem : null
379 }
380
381 // 账户不存在,返回null
382 return null
383 },
384 }
385
386 return methods
387 },
388 {
389 name: 'PublishDialogStorage',
390 version: 1,
391 },
392 'indexedDB',
393 )
394
394 lines Plain Text