| 1 | /** |
| 2 | * mediaTabStore - 媒体 Tab 状态管理 |
| 3 | * 管理视频/图片两个列表的独立状态、分页、预览 |
| 4 | * 以及"全部"Tab 的三路合并数据 |
| 5 | * 支持批量选择和删除(视频/图片/全部 Tab) |
| 6 | */ |
| 7 | |
| 8 | import type { MediaItem, PromotionMaterial } from '@/api/materials/material.types' |
| 9 | |
| 10 | import { create } from 'zustand' |
| 11 | import { combine } from 'zustand/middleware' |
| 12 | import { apiCreateDraftFromVideoUrl } from '@/api/ai/ai.api' |
| 13 | import { apiBatchDeleteMaterials, apiGetMaterialList, batchDeleteMedia, getMediaList } from '@/api/materials/material.api' |
| 14 | |
| 15 | import { |
| 16 | appendPlanDetailMaterials, |
| 17 | isCurrentPlanDetail, |
| 18 | refreshCurrentPlanDetailMaterials, |
| 19 | registerMediaTabDraftSyncAdapter, |
| 20 | setPlanDetailMaterialsFromExternal, |
| 21 | silentRefreshPlanDetailMaterials, |
| 22 | syncPlanDetailMaterialsFromFresh, |
| 23 | } from '@/store/draft-box/materialSync' |
| 24 | import { getOssUrl } from '@/utils/oss' |
| 25 | |
| 26 | const PAGE_SIZE = 20 |
| 27 | const ALL_PAGE_SIZE = 20 |
| 28 | const mediaListRequestKeys = new Set<string>() |
| 29 | const allListRequestKeys = new Set<string>() |
| 30 | |
| 31 | function getMediaListRequestKey(materialGroupId: string, type: 'video' | 'img') { |
| 32 | return `media:${materialGroupId}:${type}:1:${PAGE_SIZE}` |
| 33 | } |
| 34 | |
| 35 | function getAllListRequestKey(materialGroupId: string, planId: string) { |
| 36 | return `all:${materialGroupId}:${planId}:1:${ALL_PAGE_SIZE}` |
| 37 | } |
| 38 | |
| 39 | interface MediaTypeState { |
| 40 | list: MediaItem[] |
| 41 | loading: boolean |
| 42 | total: number |
| 43 | page: number |
| 44 | hasMore: boolean |
| 45 | initialized: boolean |
| 46 | } |
| 47 | |
| 48 | const defaultTypeState: MediaTypeState = { |
| 49 | list: [], |
| 50 | loading: false, |
| 51 | total: 0, |
| 52 | page: 1, |
| 53 | hasMore: true, |
| 54 | initialized: false, |
| 55 | } |
| 56 | |
| 57 | /** 全部 Tab 统一数据项 */ |
| 58 | export interface AllTabItem { |
| 59 | source: 'draft' | 'video' | 'img' |
| 60 | id: string |
| 61 | createdAt: string |
| 62 | data: PromotionMaterial | MediaItem |
| 63 | } |
| 64 | |
| 65 | export interface VideoDraftCreationTask { |
| 66 | mediaId: string |
| 67 | mediaTitle: string |
| 68 | groupId: string |
| 69 | videoUrl: string |
| 70 | platforms: string[] |
| 71 | startedAt: number |
| 72 | } |
| 73 | |
| 74 | interface AllTabState { |
| 75 | mergedList: AllTabItem[] |
| 76 | loading: boolean |
| 77 | initialized: boolean |
| 78 | allExhausted: boolean |
| 79 | draftPage: number |
| 80 | draftHasMore: boolean |
| 81 | draftTotal: number |
| 82 | videoPage: number |
| 83 | videoHasMore: boolean |
| 84 | videoTotal: number |
| 85 | imgPage: number |
| 86 | imgHasMore: boolean |
| 87 | imgTotal: number |
| 88 | } |
| 89 | |
| 90 | const defaultAllState: AllTabState = { |
| 91 | mergedList: [], |
| 92 | loading: false, |
| 93 | initialized: false, |
| 94 | allExhausted: false, |
| 95 | draftPage: 1, |
| 96 | draftHasMore: true, |
| 97 | draftTotal: 0, |
| 98 | videoPage: 1, |
| 99 | videoHasMore: true, |
| 100 | videoTotal: 0, |
| 101 | imgPage: 1, |
| 102 | imgHasMore: true, |
| 103 | imgTotal: 0, |
| 104 | } |
| 105 | |
| 106 | /** 将草稿转换为 AllTabItem */ |
| 107 | function materialToAllItem(m: PromotionMaterial): AllTabItem { |
| 108 | return { source: 'draft', id: m.id, createdAt: m.createdAt || '', data: m } |
| 109 | } |
| 110 | |
| 111 | /** 将媒体转换为 AllTabItem */ |
| 112 | function mediaToAllItem(m: MediaItem, source: 'video' | 'img'): AllTabItem { |
| 113 | return { source, id: m._id, createdAt: m.createdAt || '', data: m } |
| 114 | } |
| 115 | |
| 116 | /** 按 createdAt 降序排序 */ |
| 117 | function sortByCreatedAtDesc(items: AllTabItem[]): AllTabItem[] { |
| 118 | return items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) |
| 119 | } |
| 120 | |
| 121 | export const useMediaTabStore = create( |
| 122 | combine( |
| 123 | { |
| 124 | video: { ...defaultTypeState } as MediaTypeState, |
| 125 | img: { ...defaultTypeState } as MediaTypeState, |
| 126 | all: { ...defaultAllState } as AllTabState, |
| 127 | activeMaterialGroupId: null as string | null, |
| 128 | // 预览状态 |
| 129 | previewOpen: false, |
| 130 | previewIndex: 0, |
| 131 | previewType: 'video' as 'video' | 'img', |
| 132 | // 批量模式状态 |
| 133 | batchMode: false, |
| 134 | /** id → source 映射,用于全部 Tab 的混合删除 */ |
| 135 | selectedItems: {} as Record<string, 'draft' | 'video' | 'img'>, |
| 136 | batchDeleting: false, |
| 137 | /** 视频素材生成草稿 loading */ |
| 138 | creatingDraftMap: {} as Record<string, boolean>, |
| 139 | /** 视频生成草稿长任务 */ |
| 140 | draftCreationTasks: {} as Record<string, VideoDraftCreationTask>, |
| 141 | /** 视频生成草稿悬浮窗是否折叠 */ |
| 142 | draftCreationWidgetMinimized: false, |
| 143 | }, |
| 144 | (set, get) => ({ |
| 145 | /** |
| 146 | * 获取媒体列表(首次加载) |
| 147 | */ |
| 148 | fetchMediaList: async (materialGroupId: string, type: 'video' | 'img') => { |
| 149 | const requestKey = getMediaListRequestKey(materialGroupId, type) |
| 150 | const state = get()[type] |
| 151 | const isSameGroupLoading = get().activeMaterialGroupId === materialGroupId && state.loading |
| 152 | if (isSameGroupLoading || mediaListRequestKeys.has(requestKey)) { |
| 153 | return |
| 154 | } |
| 155 | |
| 156 | mediaListRequestKeys.add(requestKey) |
| 157 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 158 | set({ activeMaterialGroupId: materialGroupId }) |
| 159 | } |
| 160 | |
| 161 | set(prev => ({ |
| 162 | [type]: { ...prev[type], loading: true }, |
| 163 | })) |
| 164 | |
| 165 | try { |
| 166 | const res = await getMediaList({ materialGroupId }, 1, PAGE_SIZE, type) |
| 167 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 168 | const activeMaterialGroupId = get().activeMaterialGroupId |
| 169 | const hasActiveTypeRequest = !!activeMaterialGroupId && mediaListRequestKeys.has(getMediaListRequestKey(activeMaterialGroupId, type)) |
| 170 | if (!hasActiveTypeRequest) { |
| 171 | set(prev => ({ |
| 172 | [type]: { ...prev[type], loading: false }, |
| 173 | })) |
| 174 | } |
| 175 | return |
| 176 | } |
| 177 | |
| 178 | if (res?.data) { |
| 179 | const list = res.data.list || [] |
| 180 | const total = res.data.total || 0 |
| 181 | set({ |
| 182 | [type]: { |
| 183 | list, |
| 184 | loading: false, |
| 185 | total, |
| 186 | page: 1, |
| 187 | hasMore: list.length < total, |
| 188 | initialized: true, |
| 189 | }, |
| 190 | }) |
| 191 | } |
| 192 | else { |
| 193 | set(prev => ({ |
| 194 | [type]: { ...prev[type], loading: false, initialized: true }, |
| 195 | })) |
| 196 | } |
| 197 | } |
| 198 | catch (error) { |
| 199 | console.error(`Failed to fetch ${type} media list:`, error) |
| 200 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 201 | const activeMaterialGroupId = get().activeMaterialGroupId |
| 202 | const hasActiveTypeRequest = !!activeMaterialGroupId && mediaListRequestKeys.has(getMediaListRequestKey(activeMaterialGroupId, type)) |
| 203 | if (!hasActiveTypeRequest) { |
| 204 | set(prev => ({ |
| 205 | [type]: { ...prev[type], loading: false }, |
| 206 | })) |
| 207 | } |
| 208 | return |
| 209 | } |
| 210 | |
| 211 | set(prev => ({ |
| 212 | [type]: { ...prev[type], loading: false, initialized: true }, |
| 213 | })) |
| 214 | } |
| 215 | finally { |
| 216 | mediaListRequestKeys.delete(requestKey) |
| 217 | } |
| 218 | }, |
| 219 | |
| 220 | /** |
| 221 | * 加载更多 |
| 222 | */ |
| 223 | loadMore: async (materialGroupId: string, type: 'video' | 'img') => { |
| 224 | const state = get()[type] |
| 225 | if (state.loading || !state.hasMore) |
| 226 | return |
| 227 | |
| 228 | const nextPage = state.page + 1 |
| 229 | set(prev => ({ |
| 230 | [type]: { ...prev[type], loading: true }, |
| 231 | })) |
| 232 | |
| 233 | try { |
| 234 | const res = await getMediaList({ materialGroupId }, nextPage, PAGE_SIZE, type) |
| 235 | if (res?.data) { |
| 236 | const newList = res.data.list || [] |
| 237 | const total = res.data.total || 0 |
| 238 | const combinedList = [...state.list, ...newList] |
| 239 | set({ |
| 240 | [type]: { |
| 241 | list: combinedList, |
| 242 | loading: false, |
| 243 | total, |
| 244 | page: nextPage, |
| 245 | hasMore: combinedList.length < total, |
| 246 | initialized: true, |
| 247 | }, |
| 248 | }) |
| 249 | } |
| 250 | else { |
| 251 | set(prev => ({ |
| 252 | [type]: { ...prev[type], loading: false, hasMore: false }, |
| 253 | })) |
| 254 | } |
| 255 | } |
| 256 | catch (error) { |
| 257 | console.error(`Failed to load more ${type} media:`, error) |
| 258 | set(prev => ({ |
| 259 | [type]: { ...prev[type], loading: false }, |
| 260 | })) |
| 261 | } |
| 262 | }, |
| 263 | |
| 264 | /** |
| 265 | * 获取全部列表(首次加载,三路并行) |
| 266 | */ |
| 267 | fetchAllList: async (materialGroupId: string, planId: string) => { |
| 268 | const requestKey = getAllListRequestKey(materialGroupId, planId) |
| 269 | const { all } = get() |
| 270 | const isSameGroupLoading = get().activeMaterialGroupId === materialGroupId && all.loading |
| 271 | if (isSameGroupLoading || allListRequestKeys.has(requestKey)) { |
| 272 | return |
| 273 | } |
| 274 | |
| 275 | allListRequestKeys.add(requestKey) |
| 276 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 277 | set({ activeMaterialGroupId: materialGroupId }) |
| 278 | } |
| 279 | |
| 280 | set({ all: { ...get().all, loading: true } }) |
| 281 | |
| 282 | try { |
| 283 | const [draftRes, videoRes, imgRes] = await Promise.all([ |
| 284 | apiGetMaterialList(planId, 1, ALL_PAGE_SIZE), |
| 285 | getMediaList({ materialGroupId }, 1, ALL_PAGE_SIZE, 'video'), |
| 286 | getMediaList({ materialGroupId }, 1, ALL_PAGE_SIZE, 'img'), |
| 287 | ]) |
| 288 | |
| 289 | const draftList = draftRes?.data?.list || [] |
| 290 | const draftTotal = draftRes?.data?.total || 0 |
| 291 | const videoList = videoRes?.data?.list || [] |
| 292 | const videoTotal = videoRes?.data?.total || 0 |
| 293 | const imgList = imgRes?.data?.list || [] |
| 294 | const imgTotal = imgRes?.data?.total || 0 |
| 295 | |
| 296 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 297 | const activeMaterialGroupId = get().activeMaterialGroupId |
| 298 | const hasActiveAllRequest = !!activeMaterialGroupId && allListRequestKeys.has(getAllListRequestKey(activeMaterialGroupId, activeMaterialGroupId)) |
| 299 | if (!hasActiveAllRequest) { |
| 300 | set({ all: { ...get().all, loading: false } }) |
| 301 | } |
| 302 | return |
| 303 | } |
| 304 | |
| 305 | const allItems: AllTabItem[] = [ |
| 306 | ...draftList.map(materialToAllItem), |
| 307 | ...videoList.map(m => mediaToAllItem(m, 'video')), |
| 308 | ...imgList.map(m => mediaToAllItem(m, 'img')), |
| 309 | ] |
| 310 | |
| 311 | const draftHasMore = draftList.length < draftTotal |
| 312 | const videoHasMore = videoList.length < videoTotal |
| 313 | const imgHasMore = imgList.length < imgTotal |
| 314 | |
| 315 | set({ |
| 316 | all: { |
| 317 | mergedList: sortByCreatedAtDesc(allItems), |
| 318 | loading: false, |
| 319 | initialized: true, |
| 320 | allExhausted: !draftHasMore && !videoHasMore && !imgHasMore, |
| 321 | draftPage: 1, |
| 322 | draftHasMore, |
| 323 | draftTotal, |
| 324 | videoPage: 1, |
| 325 | videoHasMore, |
| 326 | videoTotal, |
| 327 | imgPage: 1, |
| 328 | imgHasMore, |
| 329 | imgTotal, |
| 330 | }, |
| 331 | }) |
| 332 | |
| 333 | // 同步草稿数据到 planDetailStore,"草稿"tab 直接复用,不再重复请求 |
| 334 | setPlanDetailMaterialsFromExternal(draftList, draftTotal, ALL_PAGE_SIZE) |
| 335 | } |
| 336 | catch (error) { |
| 337 | console.error('Failed to fetch all list:', error) |
| 338 | if (get().activeMaterialGroupId !== materialGroupId) { |
| 339 | const activeMaterialGroupId = get().activeMaterialGroupId |
| 340 | const hasActiveAllRequest = !!activeMaterialGroupId && allListRequestKeys.has(getAllListRequestKey(activeMaterialGroupId, activeMaterialGroupId)) |
| 341 | if (!hasActiveAllRequest) { |
| 342 | set({ all: { ...get().all, loading: false } }) |
| 343 | } |
| 344 | return |
| 345 | } |
| 346 | |
| 347 | set({ all: { ...get().all, loading: false, initialized: true } }) |
| 348 | } |
| 349 | finally { |
| 350 | allListRequestKeys.delete(requestKey) |
| 351 | } |
| 352 | }, |
| 353 | |
| 354 | /** |
| 355 | * 加载更多全部列表 |
| 356 | */ |
| 357 | loadMoreAll: async (materialGroupId: string, planId: string) => { |
| 358 | const { all } = get() |
| 359 | if (all.loading || all.allExhausted) |
| 360 | return |
| 361 | |
| 362 | set({ all: { ...all, loading: true } }) |
| 363 | |
| 364 | try { |
| 365 | const fetches: Promise<any>[] = [] |
| 366 | const fetchTypes: ('draft' | 'video' | 'img')[] = [] |
| 367 | |
| 368 | if (all.draftHasMore) { |
| 369 | fetches.push(apiGetMaterialList(planId, all.draftPage + 1, ALL_PAGE_SIZE)) |
| 370 | fetchTypes.push('draft') |
| 371 | } |
| 372 | if (all.videoHasMore) { |
| 373 | fetches.push(getMediaList({ materialGroupId }, all.videoPage + 1, ALL_PAGE_SIZE, 'video')) |
| 374 | fetchTypes.push('video') |
| 375 | } |
| 376 | if (all.imgHasMore) { |
| 377 | fetches.push(getMediaList({ materialGroupId }, all.imgPage + 1, ALL_PAGE_SIZE, 'img')) |
| 378 | fetchTypes.push('img') |
| 379 | } |
| 380 | |
| 381 | const results = await Promise.all(fetches) |
| 382 | |
| 383 | const current = get().all |
| 384 | let newDraftPage = current.draftPage |
| 385 | let newDraftHasMore = current.draftHasMore |
| 386 | let newDraftTotal = current.draftTotal |
| 387 | let newVideoPage = current.videoPage |
| 388 | let newVideoHasMore = current.videoHasMore |
| 389 | let newVideoTotal = current.videoTotal |
| 390 | let newImgPage = current.imgPage |
| 391 | let newImgHasMore = current.imgHasMore |
| 392 | let newImgTotal = current.imgTotal |
| 393 | const newItems: AllTabItem[] = [] |
| 394 | let newDraftList: any[] = [] |
| 395 | |
| 396 | results.forEach((res, i) => { |
| 397 | const type = fetchTypes[i] |
| 398 | const list = res?.data?.list || [] |
| 399 | const total = res?.data?.total || 0 |
| 400 | |
| 401 | if (type === 'draft') { |
| 402 | newDraftPage += 1 |
| 403 | newDraftTotal = total |
| 404 | newDraftHasMore = (current.mergedList.filter(item => item.source === 'draft').length + list.length) < total |
| 405 | newItems.push(...list.map(materialToAllItem)) |
| 406 | newDraftList = list |
| 407 | } |
| 408 | else if (type === 'video') { |
| 409 | newVideoPage += 1 |
| 410 | newVideoTotal = total |
| 411 | newVideoHasMore = (current.mergedList.filter(item => item.source === 'video').length + list.length) < total |
| 412 | newItems.push(...list.map((m: MediaItem) => mediaToAllItem(m, 'video'))) |
| 413 | } |
| 414 | else if (type === 'img') { |
| 415 | newImgPage += 1 |
| 416 | newImgTotal = total |
| 417 | newImgHasMore = (current.mergedList.filter(item => item.source === 'img').length + list.length) < total |
| 418 | newItems.push(...list.map((m: MediaItem) => mediaToAllItem(m, 'img'))) |
| 419 | } |
| 420 | }) |
| 421 | |
| 422 | const mergedList = sortByCreatedAtDesc([...current.mergedList, ...newItems]) |
| 423 | |
| 424 | set({ |
| 425 | all: { |
| 426 | mergedList, |
| 427 | loading: false, |
| 428 | initialized: true, |
| 429 | allExhausted: !newDraftHasMore && !newVideoHasMore && !newImgHasMore, |
| 430 | draftPage: newDraftPage, |
| 431 | draftHasMore: newDraftHasMore, |
| 432 | draftTotal: newDraftTotal, |
| 433 | videoPage: newVideoPage, |
| 434 | videoHasMore: newVideoHasMore, |
| 435 | videoTotal: newVideoTotal, |
| 436 | imgPage: newImgPage, |
| 437 | imgHasMore: newImgHasMore, |
| 438 | imgTotal: newImgTotal, |
| 439 | }, |
| 440 | }) |
| 441 | |
| 442 | // 同步新增草稿到 planDetailStore |
| 443 | if (newDraftList.length > 0) { |
| 444 | appendPlanDetailMaterials(newDraftList, newDraftTotal) |
| 445 | } |
| 446 | } |
| 447 | catch (error) { |
| 448 | console.error('Failed to load more all list:', error) |
| 449 | set({ all: { ...get().all, loading: false } }) |
| 450 | } |
| 451 | }, |
| 452 | |
| 453 | /** |
| 454 | * 静默刷新全部列表(轮询完成时调用) |
| 455 | */ |
| 456 | silentRefreshAll: async (materialGroupId: string, planId: string) => { |
| 457 | const { all } = get() |
| 458 | if (!all.initialized) |
| 459 | return |
| 460 | |
| 461 | try { |
| 462 | const [draftRes, videoRes, imgRes] = await Promise.all([ |
| 463 | apiGetMaterialList(planId, 1, ALL_PAGE_SIZE), |
| 464 | getMediaList({ materialGroupId }, 1, ALL_PAGE_SIZE, 'video'), |
| 465 | getMediaList({ materialGroupId }, 1, ALL_PAGE_SIZE, 'img'), |
| 466 | ]) |
| 467 | |
| 468 | const freshDrafts = (draftRes?.data?.list || []).map(materialToAllItem) |
| 469 | const freshVideos = (videoRes?.data?.list || []).map((m: MediaItem) => mediaToAllItem(m, 'video')) |
| 470 | const freshImgs = (imgRes?.data?.list || []).map((m: MediaItem) => mediaToAllItem(m, 'img')) |
| 471 | |
| 472 | const current = get().all |
| 473 | const existingIds = new Set(current.mergedList.map(item => item.id)) |
| 474 | const newItems = [...freshDrafts, ...freshVideos, ...freshImgs].filter(item => !existingIds.has(item.id)) |
| 475 | |
| 476 | if (newItems.length > 0) { |
| 477 | set({ |
| 478 | all: { |
| 479 | ...current, |
| 480 | mergedList: sortByCreatedAtDesc([...newItems, ...current.mergedList]), |
| 481 | draftTotal: draftRes?.data?.total || current.draftTotal, |
| 482 | videoTotal: videoRes?.data?.total || current.videoTotal, |
| 483 | imgTotal: imgRes?.data?.total || current.imgTotal, |
| 484 | }, |
| 485 | }) |
| 486 | } |
| 487 | |
| 488 | // 同步草稿数据到 planDetailStore |
| 489 | const draftList = draftRes?.data?.list || [] |
| 490 | const draftTotal = draftRes?.data?.total || 0 |
| 491 | syncPlanDetailMaterialsFromFresh(draftList, draftTotal) |
| 492 | } |
| 493 | catch { |
| 494 | // 静默失败 |
| 495 | } |
| 496 | }, |
| 497 | |
| 498 | /** |
| 499 | * 重置所有数据(Plan 切换时调用) |
| 500 | */ |
| 501 | reset: (materialGroupId?: string) => { |
| 502 | const { |
| 503 | creatingDraftMap, |
| 504 | draftCreationTasks, |
| 505 | draftCreationWidgetMinimized, |
| 506 | } = get() |
| 507 | |
| 508 | set({ |
| 509 | video: { ...defaultTypeState }, |
| 510 | img: { ...defaultTypeState }, |
| 511 | all: { ...defaultAllState }, |
| 512 | activeMaterialGroupId: materialGroupId ?? null, |
| 513 | previewOpen: false, |
| 514 | previewIndex: 0, |
| 515 | batchMode: false, |
| 516 | selectedItems: {}, |
| 517 | batchDeleting: false, |
| 518 | creatingDraftMap, |
| 519 | draftCreationTasks, |
| 520 | draftCreationWidgetMinimized, |
| 521 | }) |
| 522 | }, |
| 523 | |
| 524 | /** |
| 525 | * 静默刷新已初始化的媒体列表(轮询完成时调用) |
| 526 | */ |
| 527 | silentRefresh: async (materialGroupId: string) => { |
| 528 | const state = get() |
| 529 | const types = (['video', 'img'] as const).filter(t => state[t].initialized) |
| 530 | |
| 531 | await Promise.all(types.map(async (type) => { |
| 532 | try { |
| 533 | const current = get()[type] |
| 534 | const res = await getMediaList({ materialGroupId }, 1, PAGE_SIZE, type) |
| 535 | if (res?.data) { |
| 536 | const freshList = res.data.list || [] |
| 537 | const total = res.data.total || 0 |
| 538 | // 构建当前列表的 _id Set |
| 539 | const existingIds = new Set(current.list.map(m => m._id)) |
| 540 | // 找出新增项 |
| 541 | const newItems = freshList.filter(item => !existingIds.has(item._id)) |
| 542 | if (newItems.length > 0) { |
| 543 | set({ |
| 544 | [type]: { |
| 545 | ...current, |
| 546 | list: [...newItems, ...current.list], |
| 547 | total, |
| 548 | }, |
| 549 | }) |
| 550 | } |
| 551 | } |
| 552 | } |
| 553 | catch { |
| 554 | // 静默失败 |
| 555 | } |
| 556 | })) |
| 557 | }, |
| 558 | |
| 559 | /** |
| 560 | * 打开预览 |
| 561 | */ |
| 562 | openPreview: (type: 'video' | 'img', index: number) => { |
| 563 | set({ previewOpen: true, previewIndex: index, previewType: type }) |
| 564 | }, |
| 565 | |
| 566 | /** |
| 567 | * 关闭预览 |
| 568 | */ |
| 569 | closePreview: () => { |
| 570 | set({ previewOpen: false }) |
| 571 | }, |
| 572 | |
| 573 | setDraftCreationWidgetMinimized: (minimized: boolean) => { |
| 574 | set({ draftCreationWidgetMinimized: minimized }) |
| 575 | }, |
| 576 | |
| 577 | /** |
| 578 | * 根据视频素材生成草稿 |
| 579 | */ |
| 580 | createDraftFromVideo: async ({ |
| 581 | mediaId, |
| 582 | videoUrl, |
| 583 | groupId, |
| 584 | platforms, |
| 585 | mediaTitle, |
| 586 | }: { |
| 587 | mediaId: string |
| 588 | videoUrl: string |
| 589 | groupId: string |
| 590 | platforms?: string[] |
| 591 | mediaTitle?: string |
| 592 | }) => { |
| 593 | if (get().creatingDraftMap[mediaId]) { |
| 594 | return { |
| 595 | success: false as const, |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | set(state => ({ |
| 600 | creatingDraftMap: { |
| 601 | ...state.creatingDraftMap, |
| 602 | [mediaId]: true, |
| 603 | }, |
| 604 | draftCreationTasks: { |
| 605 | ...state.draftCreationTasks, |
| 606 | [mediaId]: { |
| 607 | mediaId, |
| 608 | mediaTitle: mediaTitle?.trim() || '', |
| 609 | groupId, |
| 610 | videoUrl, |
| 611 | platforms: platforms || [], |
| 612 | startedAt: Date.now(), |
| 613 | }, |
| 614 | }, |
| 615 | draftCreationWidgetMinimized: Object.keys(state.draftCreationTasks).length > 0 |
| 616 | ? state.draftCreationWidgetMinimized |
| 617 | : false, |
| 618 | })) |
| 619 | |
| 620 | try { |
| 621 | const res = await apiCreateDraftFromVideoUrl({ |
| 622 | videoUrl: getOssUrl(videoUrl), |
| 623 | groupId, |
| 624 | platforms, |
| 625 | }) |
| 626 | |
| 627 | if (res?.code !== 0 || !res?.data?.materialId) { |
| 628 | return { |
| 629 | success: false as const, |
| 630 | message: res?.message, |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | const refreshTasks: Promise<unknown>[] = [] |
| 635 | |
| 636 | if (isCurrentPlanDetail(groupId)) { |
| 637 | if (get().all.initialized) { |
| 638 | refreshTasks.push(useMediaTabStore.getState().silentRefreshAll(groupId, groupId)) |
| 639 | } |
| 640 | else { |
| 641 | refreshTasks.push(silentRefreshPlanDetailMaterials(groupId)) |
| 642 | } |
| 643 | } |
| 644 | |
| 645 | if (refreshTasks.length > 0) { |
| 646 | await Promise.all(refreshTasks) |
| 647 | } |
| 648 | |
| 649 | return { |
| 650 | success: true as const, |
| 651 | materialId: res.data.materialId, |
| 652 | } |
| 653 | } |
| 654 | catch { |
| 655 | return { |
| 656 | success: false as const, |
| 657 | } |
| 658 | } |
| 659 | finally { |
| 660 | set((state) => { |
| 661 | const nextCreatingDraftMap = { ...state.creatingDraftMap } |
| 662 | const nextDraftCreationTasks = { ...state.draftCreationTasks } |
| 663 | delete nextCreatingDraftMap[mediaId] |
| 664 | delete nextDraftCreationTasks[mediaId] |
| 665 | |
| 666 | return { |
| 667 | creatingDraftMap: nextCreatingDraftMap, |
| 668 | draftCreationTasks: nextDraftCreationTasks, |
| 669 | } |
| 670 | }) |
| 671 | } |
| 672 | }, |
| 673 | |
| 674 | // ===== 批量模式 ===== |
| 675 | |
| 676 | enterBatchMode: () => { |
| 677 | set({ batchMode: true, selectedItems: {} }) |
| 678 | }, |
| 679 | |
| 680 | exitBatchMode: () => { |
| 681 | set({ batchMode: false, selectedItems: {} }) |
| 682 | }, |
| 683 | |
| 684 | toggleSelection: (id: string, source: 'draft' | 'video' | 'img') => { |
| 685 | const { selectedItems } = get() |
| 686 | const newSelected = { ...selectedItems } |
| 687 | if (newSelected[id]) { |
| 688 | delete newSelected[id] |
| 689 | } |
| 690 | else { |
| 691 | newSelected[id] = source |
| 692 | } |
| 693 | set({ selectedItems: newSelected }) |
| 694 | }, |
| 695 | |
| 696 | /** 全选当前已加载的列表项(按 Tab 类型) */ |
| 697 | selectAllLoaded: (tab: 'video' | 'img' | 'all') => { |
| 698 | const state = get() |
| 699 | const newSelected: Record<string, 'draft' | 'video' | 'img'> = {} |
| 700 | |
| 701 | if (tab === 'all') { |
| 702 | state.all.mergedList.forEach((item) => { |
| 703 | newSelected[item.id] = item.source |
| 704 | }) |
| 705 | } |
| 706 | else { |
| 707 | state[tab].list.forEach((media) => { |
| 708 | newSelected[media._id] = tab |
| 709 | }) |
| 710 | } |
| 711 | |
| 712 | set({ selectedItems: newSelected }) |
| 713 | }, |
| 714 | |
| 715 | deselectAll: () => { |
| 716 | set({ selectedItems: {} }) |
| 717 | }, |
| 718 | |
| 719 | /** 获取当前选中数量 */ |
| 720 | getSelectedCount: () => { |
| 721 | return Object.keys(get().selectedItems).length |
| 722 | }, |
| 723 | |
| 724 | /** |
| 725 | * 批量删除媒体(视频/图片 Tab) |
| 726 | * 删除成功后重新拉取列表 |
| 727 | */ |
| 728 | batchDeleteByType: async (materialGroupId: string, type: 'video' | 'img') => { |
| 729 | const { selectedItems } = get() |
| 730 | const ids = Object.entries(selectedItems) |
| 731 | .filter(([_, source]) => source === type) |
| 732 | .map(([id]) => id) |
| 733 | |
| 734 | if (ids.length === 0) |
| 735 | return false |
| 736 | |
| 737 | set({ batchDeleting: true }) |
| 738 | try { |
| 739 | const res = await batchDeleteMedia(ids) |
| 740 | if (res?.code !== 0) |
| 741 | return false |
| 742 | |
| 743 | // 从列表中移除已删除项 |
| 744 | const deletedSet = new Set(ids) |
| 745 | const current = get()[type] |
| 746 | const newList = current.list.filter(m => !deletedSet.has(m._id)) |
| 747 | set({ |
| 748 | [type]: { |
| 749 | ...current, |
| 750 | list: newList, |
| 751 | total: current.total - ids.length, |
| 752 | }, |
| 753 | batchMode: false, |
| 754 | selectedItems: {}, |
| 755 | }) |
| 756 | |
| 757 | // 同步更新全部 Tab |
| 758 | const methods = useMediaTabStore.getState() |
| 759 | methods.removeItemsFromAll(ids, type) |
| 760 | |
| 761 | return true |
| 762 | } |
| 763 | catch { |
| 764 | return false |
| 765 | } |
| 766 | finally { |
| 767 | set({ batchDeleting: false }) |
| 768 | } |
| 769 | }, |
| 770 | |
| 771 | /** |
| 772 | * 批量删除全部 Tab(混合删除) |
| 773 | * 按 source 分组,草稿调 apiBatchDeleteMaterials,媒体调 batchDeleteMedia |
| 774 | */ |
| 775 | batchDeleteAll: async (materialGroupId: string, planId: string) => { |
| 776 | const { selectedItems } = get() |
| 777 | const entries = Object.entries(selectedItems) |
| 778 | if (entries.length === 0) |
| 779 | return false |
| 780 | |
| 781 | // 按 source 分组 |
| 782 | const draftIds: string[] = [] |
| 783 | const mediaIds: string[] = [] |
| 784 | entries.forEach(([id, source]) => { |
| 785 | if (source === 'draft') { |
| 786 | draftIds.push(id) |
| 787 | } |
| 788 | else { |
| 789 | mediaIds.push(id) |
| 790 | } |
| 791 | }) |
| 792 | |
| 793 | set({ batchDeleting: true }) |
| 794 | try { |
| 795 | const promises: Promise<any>[] = [] |
| 796 | if (draftIds.length > 0) { |
| 797 | promises.push(apiBatchDeleteMaterials(draftIds)) |
| 798 | } |
| 799 | if (mediaIds.length > 0) { |
| 800 | promises.push(batchDeleteMedia(mediaIds)) |
| 801 | } |
| 802 | |
| 803 | const results = await Promise.all(promises) |
| 804 | const allSuccess = results.every(res => res?.code === 0) |
| 805 | if (!allSuccess) |
| 806 | return false |
| 807 | |
| 808 | // 从 all.mergedList 中移除已删除项 |
| 809 | const deletedSet = new Set(entries.map(([id]) => id)) |
| 810 | const current = get().all |
| 811 | const newMergedList = current.mergedList.filter(item => !deletedSet.has(item.id)) |
| 812 | |
| 813 | const videoDeletedIds = entries.filter(([_, s]) => s === 'video').map(([id]) => id) |
| 814 | const imgDeletedIds = entries.filter(([_, s]) => s === 'img').map(([id]) => id) |
| 815 | |
| 816 | set({ |
| 817 | all: { |
| 818 | ...current, |
| 819 | mergedList: newMergedList, |
| 820 | draftTotal: current.draftTotal - draftIds.length, |
| 821 | videoTotal: current.videoTotal - videoDeletedIds.length, |
| 822 | imgTotal: current.imgTotal - imgDeletedIds.length, |
| 823 | }, |
| 824 | batchMode: false, |
| 825 | selectedItems: {}, |
| 826 | }) |
| 827 | const state = get() |
| 828 | |
| 829 | if (videoDeletedIds.length > 0 && state.video.initialized) { |
| 830 | const videoDeletedSet = new Set(videoDeletedIds) |
| 831 | set({ |
| 832 | video: { |
| 833 | ...state.video, |
| 834 | list: state.video.list.filter(m => !videoDeletedSet.has(m._id)), |
| 835 | total: state.video.total - videoDeletedIds.length, |
| 836 | }, |
| 837 | }) |
| 838 | } |
| 839 | |
| 840 | if (imgDeletedIds.length > 0 && state.img.initialized) { |
| 841 | const imgDeletedSet = new Set(imgDeletedIds) |
| 842 | set({ |
| 843 | img: { |
| 844 | ...state.img, |
| 845 | list: state.img.list.filter(m => !imgDeletedSet.has(m._id)), |
| 846 | total: state.img.total - imgDeletedIds.length, |
| 847 | }, |
| 848 | }) |
| 849 | } |
| 850 | |
| 851 | // 同步 planDetailStore 中的草稿列表 |
| 852 | if (draftIds.length > 0) { |
| 853 | await refreshCurrentPlanDetailMaterials() |
| 854 | } |
| 855 | |
| 856 | return true |
| 857 | } |
| 858 | catch { |
| 859 | return false |
| 860 | } |
| 861 | finally { |
| 862 | set({ batchDeleting: false }) |
| 863 | } |
| 864 | }, |
| 865 | |
| 866 | /** |
| 867 | * 从全部 Tab 的 mergedList 中移除指定项(供外部 store 调用) |
| 868 | */ |
| 869 | removeItemsFromAll: (ids: string[], source: 'draft' | 'video' | 'img') => { |
| 870 | const { all } = get() |
| 871 | if (!all.initialized) |
| 872 | return |
| 873 | |
| 874 | const deletedSet = new Set(ids) |
| 875 | const newMergedList = all.mergedList.filter(item => !deletedSet.has(item.id)) |
| 876 | |
| 877 | const totalKey = `${source === 'draft' ? 'draft' : source}Total` as 'draftTotal' | 'videoTotal' | 'imgTotal' |
| 878 | set({ |
| 879 | all: { |
| 880 | ...all, |
| 881 | mergedList: newMergedList, |
| 882 | [totalKey]: Math.max(0, all[totalKey] - ids.length), |
| 883 | }, |
| 884 | }) |
| 885 | }, |
| 886 | }), |
| 887 | ), |
| 888 | ) |
| 889 | |
| 890 | registerMediaTabDraftSyncAdapter({ |
| 891 | removeDraftItems: (ids) => { |
| 892 | useMediaTabStore.getState().removeItemsFromAll(ids, 'draft') |
| 893 | }, |
| 894 | refreshDraftItems: (materialGroupId, planId) => { |
| 895 | const mediaStore = useMediaTabStore.getState() |
| 896 | if (mediaStore.all.initialized) { |
| 897 | void mediaStore.fetchAllList(materialGroupId, planId) |
| 898 | } |
| 899 | }, |
| 900 | }) |
| 901 |