返回 AiToEarn
mediaTabStore.ts
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
901 lines TYPESCRIPT