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