返回 AiToEarn
planDetailStore.ts
根目录 / project / aitoearn-web / src / store / draft-box / planDetailStore.ts
1 import type { DraftGenerationRequest, DraftGenerationTask, ImageModelType, ImageTextDraftType, VideoDraftType } from '@/api/ai/ai.types'
2 import type { MaterialFilterDeleteParams, MaterialListFilters, MaterialPagination, PlanStatistics, PromotionMaterial, PromotionPlan, PublishRecord } from '@/api/materials/material.types'
3 import type { PlatType } from '@/app/config/platConfig'
4
5 import lodash from 'lodash'
6 import { create } from 'zustand'
7 import { combine } from 'zustand/middleware'
8 import {
9 apiCreateDraftGeneration,
10 apiCreateImageTextDraft,
11 apiGetDraftGenerationList,
12 apiGetDraftGenerationStats,
13 apiQueryDraftGenerationTasks,
14 } from '@/api/ai/ai.api'
15 import {
16 apiBatchDeleteMaterials,
17 apiDeleteMaterial,
18 apiFilterDeleteMaterials,
19 apiGetMaterialInfo,
20 apiGetMaterialList,
21 } from '@/api/materials/material.api'
22 import { usePublishDialogStorageStore } from '@/components/PublishDialog/usePublishDialogStorageStore'
23
24 import { toast } from '@/utils/ui/toast'
25 import {
26 refreshDraftItemsInMediaTabs,
27 registerPlanDetailMaterialSyncAdapter,
28 removeDraftItemsFromMediaTabs,
29 } from './materialSync'
30
31 const DRAFT_GENERATION_QUERY_BATCH_SIZE = 10
32
33 type PublishRecordSource = string
34
35 interface BatchGenerationCreateResult {
36 success: boolean
37 successCount: number
38 failedCount: number
39 taskCount: number
40 errorMessage?: string
41 }
42
43 interface VideoModelGenerationInput {
44 modelType: string
45 resolution?: string
46 duration?: number
47 aspectRatio?: string
48 }
49
50 function isDraftGenerationTaskForGroup(task: DraftGenerationTask, groupId: string) {
51 return task.request?.groupId === groupId
52 }
53
54 function sortDraftGenerationTasks(tasks: DraftGenerationTask[]) {
55 return [...tasks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
56 }
57
58 function countGeneratingTasks(tasks: DraftGenerationTask[]) {
59 return tasks.filter(task => task.status === 'generating').length
60 }
61
62 function mergeDraftGenerationTasks(current: DraftGenerationTask[], incoming: DraftGenerationTask[]) {
63 const taskMap = new Map<string, DraftGenerationTask>()
64 current.forEach(task => taskMap.set(task.id, task))
65 incoming.forEach((task) => {
66 const currentTask = taskMap.get(task.id)
67 taskMap.set(task.id, currentTask ? { ...currentTask, ...task, queue: task.queue } : task)
68 })
69 return sortDraftGenerationTasks(Array.from(taskMap.values()))
70 }
71
72 function buildDraftGenerationTaskPlaceholder(
73 id: string,
74 request: DraftGenerationRequest,
75 ): DraftGenerationTask {
76 const now = new Date().toISOString()
77 return {
78 id,
79 status: 'generating',
80 points: 0,
81 request,
82 response: {},
83 createdAt: now,
84 updatedAt: now,
85 }
86 }
87
88 function chunkTaskIds(taskIds: string[]) {
89 const chunks: string[][] = []
90 for (let i = 0; i < taskIds.length; i += DRAFT_GENERATION_QUERY_BATCH_SIZE) {
91 chunks.push(taskIds.slice(i, i + DRAFT_GENERATION_QUERY_BATCH_SIZE))
92 }
93 return chunks
94 }
95
96 function getSettledErrorMessage(result: PromiseRejectedResult) {
97 if (result.reason instanceof Error)
98 return result.reason.message
99 return typeof result.reason === 'string' ? result.reason : undefined
100 }
101
102 // Store 状态类型
103 export interface IPlanDetailStoreState {
104 // 已初始化的 planId,用于防止重复请求
105 initializedPlanId: string | null
106
107 // 当前计划
108 currentPlan: PromotionPlan | null
109 planLoading: boolean
110
111 // 素材列表
112 materials: PromotionMaterial[]
113 materialsLoading: boolean
114 materialsInitialized: boolean
115 materialsPagination: MaterialPagination
116
117 // 发布记录
118 publishRecords: PublishRecord[]
119 publishRecordsLoading: boolean
120 publishRecordsPagination: MaterialPagination
121
122 // 统计数据
123 statistics: PlanStatistics | null
124 statisticsLoading: boolean
125
126 // 弹窗状态
127 createMaterialModalOpen: boolean
128 editingMaterial: PromotionMaterial | null
129
130 // 草稿详情弹窗状态
131 draftDetailDialogOpen: boolean
132 selectedDraft: PromotionMaterial | null
133
134 // 发布弹框状态
135 publishDialogOpen: boolean
136 publishingDraft: PromotionMaterial | null
137
138 // 加载状态
139 isSubmitting: boolean
140
141 // AI 批量生成
142 generatingCount: number
143 generationTasks: DraftGenerationTask[]
144 aiBatchModalOpen: boolean
145 generationDetailDialogOpen: boolean
146 isGeneratingBatch: boolean
147
148 // 搜索/筛选 & 批量操作
149 materialsFilter: MaterialListFilters
150 batchMode: boolean
151 selectedMaterialIds: string[]
152 batchDeleting: boolean
153 conditionalDeleteDialogOpen: boolean
154
155 // 数据分析数据是否已加载
156 analyticsInitialized: boolean
157 PublishRecordSource?: PublishRecordSource
158 }
159
160 // 初始状态
161 const initialState: IPlanDetailStoreState = {
162 initializedPlanId: null,
163
164 currentPlan: null,
165 planLoading: true,
166
167 materials: [],
168 materialsLoading: true,
169 materialsInitialized: false,
170 materialsPagination: {
171 current: 1,
172 pageSize: 12,
173 total: 0,
174 hasMore: true,
175 },
176
177 publishRecords: [],
178 publishRecordsLoading: true,
179 publishRecordsPagination: {
180 current: 1,
181 pageSize: 10,
182 total: 0,
183 hasMore: true,
184 },
185
186 statistics: null,
187 statisticsLoading: true,
188
189 createMaterialModalOpen: false,
190 editingMaterial: null,
191
192 draftDetailDialogOpen: false,
193 selectedDraft: null,
194
195 publishDialogOpen: false,
196 publishingDraft: null,
197
198 isSubmitting: false,
199
200 generatingCount: 0,
201 generationTasks: [],
202 aiBatchModalOpen: false,
203 generationDetailDialogOpen: false,
204 isGeneratingBatch: false,
205
206 materialsFilter: {},
207 batchMode: false,
208 selectedMaterialIds: [],
209 batchDeleting: false,
210 conditionalDeleteDialogOpen: false,
211
212 analyticsInitialized: false,
213 }
214
215 function getInitialState() {
216 return lodash.cloneDeep(initialState)
217 }
218
219 export const usePlanDetailStore = create(
220 combine(getInitialState(), (set, get) => {
221 const methods = {
222 // ==================== 计划详情 ====================
223
224 /**
225 * 获取计划详情
226 */
227 fetchPlanDetail: async (planId: string) => {
228 set({ planLoading: true })
229 try {
230 const res = await apiGetMaterialInfo(planId)
231 const plan = res?.data as PromotionPlan
232 set({ currentPlan: plan })
233 return plan
234 }
235 catch (error) {
236 return null
237 }
238 finally {
239 set({ planLoading: false })
240 }
241 },
242
243 // ==================== 素材列表 ====================
244
245 /**
246 * 获取素材列表
247 */
248 fetchMaterials: async (planId: string, page: number = 1) => {
249 set({ materialsLoading: true })
250 try {
251 const { materialsPagination, materialsFilter } = get()
252 const res = await apiGetMaterialList(planId, page, materialsPagination.pageSize, materialsFilter)
253 const resData = res?.data as { list?: any[], total?: number } | undefined
254 const list = (resData?.list || []) as PromotionMaterial[]
255 const total = resData?.total || 0
256
257 set({
258 materials: list,
259 materialsInitialized: true,
260 materialsPagination: {
261 ...materialsPagination,
262 current: page,
263 total,
264 hasMore: list.length === materialsPagination.pageSize,
265 },
266 })
267 }
268 catch (error) {
269 // 错误由调用方处理
270 }
271 finally {
272 set({ materialsLoading: false })
273 }
274 },
275
276 /**
277 * 加载更多素材(无限滚动)
278 */
279 loadMoreMaterials: async (planId: string) => {
280 const { materialsLoading, materialsPagination, materials, materialsFilter } = get()
281 if (materialsLoading || !materialsPagination.hasMore)
282 return
283
284 set({ materialsLoading: true })
285 try {
286 const nextPage = materialsPagination.current + 1
287 const res = await apiGetMaterialList(planId, nextPage, materialsPagination.pageSize, materialsFilter)
288 const resData = res?.data as { list?: any[], total?: number } | undefined
289 const list = (resData?.list || []) as PromotionMaterial[]
290 const total = resData?.total || 0
291
292 set({
293 materials: [...materials, ...list],
294 materialsPagination: {
295 ...materialsPagination,
296 current: nextPage,
297 total,
298 hasMore: list.length === materialsPagination.pageSize,
299 },
300 })
301 }
302 catch {
303 // 错误由调用方处理
304 }
305 finally {
306 set({ materialsLoading: false })
307 }
308 },
309
310 /**
311 * 删除素材
312 */
313 deleteMaterial: async (materialId: string): Promise<boolean> => {
314 set({ isSubmitting: true })
315 try {
316 const res = await apiDeleteMaterial(materialId)
317 if (res?.code !== 0)
318 return false
319 const { currentPlan, materialsPagination } = get()
320 if (currentPlan) {
321 await methods.fetchMaterials(currentPlan.id, materialsPagination.current)
322
323 removeDraftItemsFromMediaTabs([materialId])
324 }
325 return true
326 }
327 catch {
328 return false
329 }
330 finally {
331 set({ isSubmitting: false })
332 }
333 },
334
335 // ==================== 发布记录 ====================
336
337 /**
338 * 获取发布记录
339 */
340 fetchPublishRecords: async (_planId: string, page: number = 1, source?: PublishRecordSource) => {
341 const { publishRecordsPagination } = get()
342 set({
343 PublishRecordSource: source ?? get().PublishRecordSource,
344 publishRecords: [],
345 publishRecordsLoading: false,
346 publishRecordsPagination: {
347 ...publishRecordsPagination,
348 current: page,
349 total: 0,
350 hasMore: false,
351 },
352 })
353 },
354
355 loadMorePublishRecords: async (_planId: string) => {
356 set({ publishRecordsLoading: false })
357 },
358
359 fetchStatistics: async (_planId: string, source?: PublishRecordSource) => {
360 set({
361 PublishRecordSource: source ?? get().PublishRecordSource,
362 statistics: {
363 materialCount: 0,
364 publishCount: 0,
365 viewCount: 0,
366 likeCount: 0,
367 commentCount: 0,
368 shareCount: 0,
369 favoriteCount: 0,
370 },
371 statisticsLoading: false,
372 })
373 },
374
375 // ==================== 弹窗控制 ====================
376
377 openCreateMaterialModal: () => {
378 set({ createMaterialModalOpen: true, editingMaterial: null })
379 },
380
381 openEditMaterialModal: (material: PromotionMaterial) => {
382 set({ createMaterialModalOpen: true, editingMaterial: material })
383 },
384
385 closeMaterialModal: () => {
386 set({ createMaterialModalOpen: false, editingMaterial: null })
387 },
388
389 // ==================== 草稿详情弹窗 ====================
390
391 openDraftDetailDialog: (material: PromotionMaterial) => {
392 set({ draftDetailDialogOpen: true, selectedDraft: material })
393 },
394
395 closeDraftDetailDialog: () => {
396 set({ draftDetailDialogOpen: false, selectedDraft: null })
397 },
398
399 // ==================== 发布弹框 ====================
400
401 openPublishDialog: (draft: PromotionMaterial) => {
402 // 清空上次发布的缓存数据,避免 PublishDialog 触发「是否恢复」确认弹框
403 usePublishDialogStorageStore.getState().clearPubData()
404 set({ publishDialogOpen: true, publishingDraft: draft })
405 },
406
407 closePublishDialog: () => {
408 set({ publishDialogOpen: false, publishingDraft: null })
409 },
410
411 // ==================== AI 批量生成 ====================
412
413 openAiBatchModal: () => {
414 set({ aiBatchModalOpen: true })
415 },
416
417 closeAiBatchModal: () => {
418 set({ aiBatchModalOpen: false })
419 },
420
421 openGenerationDetailDialog: () => {
422 set({ generationDetailDialogOpen: true })
423 },
424
425 closeGenerationDetailDialog: () => {
426 set({ generationDetailDialogOpen: false })
427 },
428
429 syncGenerationTasks: (tasks: DraftGenerationTask[]) => {
430 const groupId = get().currentPlan?.id || get().initializedPlanId
431 const currentTaskIds = new Set(get().generationTasks.map(task => task.id))
432 const scopedTasks = groupId
433 ? tasks.filter(task => isDraftGenerationTaskForGroup(task, groupId) || currentTaskIds.has(task.id))
434 : tasks
435 const generationTasks = mergeDraftGenerationTasks(get().generationTasks, scopedTasks)
436 set({
437 generationTasks,
438 generatingCount: countGeneratingTasks(generationTasks),
439 })
440 },
441
442 replaceGenerationTasks: (tasks: DraftGenerationTask[]) => {
443 const groupId = get().currentPlan?.id || get().initializedPlanId
444 const generationTasks = sortDraftGenerationTasks(
445 groupId ? tasks.filter(task => isDraftGenerationTaskForGroup(task, groupId)) : tasks,
446 )
447 set({
448 generationTasks,
449 generatingCount: countGeneratingTasks(generationTasks),
450 })
451 },
452
453 /**
454 * 初始化当前草稿箱生成中任务,用于刷新页面后恢复进度展示
455 */
456 fetchGenerationTasks: async (planId: string) => {
457 try {
458 const res = await apiGetDraftGenerationList(1, 100)
459 const list = (res?.data?.list || []).filter(
460 task => task.status === 'generating' && isDraftGenerationTaskForGroup(task, planId),
461 )
462 methods.replaceGenerationTasks(list)
463 }
464 catch (error) {
465 // 静默失败
466 }
467 },
468
469 /**
470 * 根据已知 taskIds 刷新生成任务详情
471 */
472 queryGenerationTasks: async (taskIds: string[]) => {
473 if (taskIds.length === 0)
474 return []
475
476 try {
477 const results = await Promise.all(
478 chunkTaskIds(taskIds).map(async (ids) => {
479 const res = await apiQueryDraftGenerationTasks(ids)
480 return res?.data || []
481 }),
482 )
483 const tasks = results.flat()
484 methods.syncGenerationTasks(tasks)
485 return tasks
486 }
487 catch {
488 return []
489 }
490 },
491
492 /**
493 * 创建 AI 批量生成任务
494 */
495 createBatchGeneration: async (
496 quantity: number,
497 modelType: string,
498 duration?: number,
499 resolution?: string,
500 aspectRatio?: string,
501 prompt?: string,
502 imageUrls?: string[],
503 videoUrls?: string[],
504 audioUrls?: string[],
505 overrideGroupId?: string,
506 platforms?: PlatType[],
507 draftType?: VideoDraftType,
508 captionPrompt?: string,
509 ) => {
510 const groupId = overrideGroupId || get().currentPlan?.id
511 if (!groupId)
512 return false
513
514 set({ isGeneratingBatch: true })
515 try {
516 const res = await apiCreateDraftGeneration({
517 quantity,
518 groupId,
519 model: modelType,
520 duration,
521 resolution,
522 aspectRatio,
523 prompt,
524 captionPrompt: captionPrompt || undefined,
525 imageUrls,
526 videoUrls,
527 audioUrls,
528 platforms: platforms?.length ? platforms : undefined,
529 draftType,
530 })
531 if (res?.code === 0) {
532 const taskIds = res.data?.taskIds || []
533 methods.syncGenerationTasks(taskIds.map(id => buildDraftGenerationTaskPlaceholder(id, {
534 groupId,
535 model: modelType,
536 duration,
537 resolution,
538 aspectRatio,
539 prompt,
540 captionPrompt: captionPrompt || undefined,
541 imageUrls,
542 videoUrls,
543 audioUrls,
544 platforms,
545 draftType,
546 })))
547 return true
548 }
549 else {
550 if (res?.message)
551 toast.error(res.message)
552 return false
553 }
554 }
555 catch {
556 return false
557 }
558 finally {
559 set({ isGeneratingBatch: false })
560 }
561 },
562
563 /**
564 * 按多个视频模型并发创建 AI 批量生成任务
565 */
566 createBatchGenerationWithModels: async (
567 quantity: number,
568 modelInputs: VideoModelGenerationInput[],
569 duration?: number,
570 aspectRatio?: string,
571 prompt?: string,
572 imageUrls?: string[],
573 videoUrls?: string[],
574 audioUrls?: string[],
575 overrideGroupId?: string,
576 platforms?: PlatType[],
577 draftType?: VideoDraftType,
578 captionPrompt?: string,
579 ): Promise<BatchGenerationCreateResult> => {
580 const groupId = overrideGroupId || get().currentPlan?.id
581 const uniqueModelInputs = Array.from(
582 new Map(modelInputs.filter(item => item.modelType).map(item => [item.modelType, item])).values(),
583 )
584 if (!groupId || uniqueModelInputs.length === 0) {
585 return { success: false, successCount: 0, failedCount: uniqueModelInputs.length, taskCount: 0 }
586 }
587
588 set({ isGeneratingBatch: true })
589 try {
590 const results = await Promise.allSettled(
591 uniqueModelInputs.map(async ({ modelType, resolution, duration: inputDuration, aspectRatio: inputAspectRatio }) => {
592 const resolvedDuration = inputDuration ?? duration
593 const resolvedAspectRatio = inputAspectRatio ?? aspectRatio
594 const res = await apiCreateDraftGeneration({
595 quantity,
596 groupId,
597 model: modelType,
598 duration: resolvedDuration,
599 resolution,
600 aspectRatio: resolvedAspectRatio,
601 prompt,
602 captionPrompt: captionPrompt || undefined,
603 imageUrls,
604 videoUrls,
605 audioUrls,
606 platforms: platforms?.length ? platforms : undefined,
607 draftType,
608 })
609
610 if (res?.code !== 0)
611 throw new Error(res?.message || 'Failed to create generation task')
612
613 return {
614 modelType,
615 resolution,
616 duration: resolvedDuration,
617 aspectRatio: resolvedAspectRatio,
618 taskIds: res.data?.taskIds || [],
619 }
620 }),
621 )
622
623 const fulfilled = results.filter(result => result.status === 'fulfilled')
624 const placeholders = results.flatMap((result) => {
625 if (result.status !== 'fulfilled')
626 return []
627 return result.value.taskIds.map((id: string) => buildDraftGenerationTaskPlaceholder(id, {
628 groupId,
629 model: result.value.modelType,
630 duration: result.value.duration,
631 resolution: result.value.resolution,
632 aspectRatio: result.value.aspectRatio,
633 prompt,
634 captionPrompt: captionPrompt || undefined,
635 imageUrls,
636 videoUrls,
637 audioUrls,
638 platforms,
639 draftType,
640 }))
641 })
642
643 if (placeholders.length > 0)
644 methods.syncGenerationTasks(placeholders)
645
646 const failed = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
647 return {
648 success: fulfilled.length > 0,
649 successCount: fulfilled.length,
650 failedCount: failed.length,
651 taskCount: placeholders.length,
652 errorMessage: failed.map(getSettledErrorMessage).find(Boolean),
653 }
654 }
655 finally {
656 set({ isGeneratingBatch: false })
657 }
658 },
659
660 /**
661 * 创建 AI 图文批量生成任务
662 */
663 createImageTextBatchGeneration: async (
664 quantity: number,
665 imageModel: ImageModelType,
666 prompt: string,
667 imageCount?: number,
668 aspectRatio?: string,
669 imageUrls?: string[],
670 overrideGroupId?: string,
671 imageSize?: string,
672 platforms?: PlatType[],
673 draftType?: ImageTextDraftType,
674 captionPrompt?: string,
675 ) => {
676 const groupId = overrideGroupId || get().currentPlan?.id
677 if (!groupId)
678 return false
679
680 set({ isGeneratingBatch: true })
681 try {
682 const res = await apiCreateImageTextDraft({
683 quantity,
684 groupId,
685 prompt,
686 captionPrompt: captionPrompt || undefined,
687 imageModel,
688 imageCount,
689 imageUrls,
690 aspectRatio,
691 imageSize,
692 platforms: platforms?.length ? platforms : undefined,
693 draftType,
694 })
695 if (res?.code === 0) {
696 const taskIds = res.data?.taskIds || []
697 methods.syncGenerationTasks(taskIds.map(id => buildDraftGenerationTaskPlaceholder(id, {
698 groupId,
699 imageModel,
700 prompt,
701 captionPrompt: captionPrompt || undefined,
702 imageCount,
703 imageUrls,
704 aspectRatio,
705 imageSize,
706 platforms,
707 draftType,
708 })))
709 return true
710 }
711 else {
712 if (res?.message)
713 toast.error(res.message)
714 return false
715 }
716 }
717 catch {
718 return false
719 }
720 finally {
721 set({ isGeneratingBatch: false })
722 }
723 },
724
725 /**
726 * 按多个图片模型并发创建 AI 图文批量生成任务
727 */
728 createImageTextBatchGenerationWithModels: async (
729 quantity: number,
730 imageModels: ImageModelType[],
731 prompt: string,
732 imageCount?: number,
733 aspectRatio?: string,
734 imageUrls?: string[],
735 overrideGroupId?: string,
736 imageSize?: string,
737 platforms?: PlatType[],
738 draftType?: ImageTextDraftType,
739 captionPrompt?: string,
740 ): Promise<BatchGenerationCreateResult> => {
741 const groupId = overrideGroupId || get().currentPlan?.id
742 const uniqueImageModels = [...new Set(imageModels.filter(Boolean))]
743 if (!groupId || uniqueImageModels.length === 0) {
744 return { success: false, successCount: 0, failedCount: uniqueImageModels.length, taskCount: 0 }
745 }
746
747 set({ isGeneratingBatch: true })
748 try {
749 const results = await Promise.allSettled(
750 uniqueImageModels.map(async (imageModel) => {
751 const res = await apiCreateImageTextDraft({
752 quantity,
753 groupId,
754 prompt,
755 captionPrompt: captionPrompt || undefined,
756 imageModel,
757 imageCount,
758 imageUrls,
759 aspectRatio,
760 imageSize,
761 platforms: platforms?.length ? platforms : undefined,
762 draftType,
763 })
764
765 if (res?.code !== 0)
766 throw new Error(res?.message || 'Failed to create generation task')
767
768 return {
769 imageModel,
770 taskIds: res.data?.taskIds || [],
771 }
772 }),
773 )
774
775 const fulfilled = results.filter((result): result is PromiseFulfilledResult<{ imageModel: ImageModelType, taskIds: string[] }> => result.status === 'fulfilled')
776 const placeholders = fulfilled.flatMap(({ value }) => value.taskIds.map(id => buildDraftGenerationTaskPlaceholder(id, {
777 groupId,
778 imageModel: value.imageModel,
779 prompt,
780 captionPrompt: captionPrompt || undefined,
781 imageCount,
782 imageUrls,
783 aspectRatio,
784 imageSize,
785 platforms,
786 draftType,
787 })))
788
789 if (placeholders.length > 0)
790 methods.syncGenerationTasks(placeholders)
791
792 const failed = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
793 return {
794 success: fulfilled.length > 0,
795 successCount: fulfilled.length,
796 failedCount: failed.length,
797 taskCount: placeholders.length,
798 errorMessage: failed.map(getSettledErrorMessage).find(Boolean),
799 }
800 }
801 finally {
802 set({ isGeneratingBatch: false })
803 }
804 },
805
806 /**
807 * 获取生成中任务数量(初始化时调用)
808 */
809 fetchGeneratingStats: async () => {
810 try {
811 const res = await apiGetDraftGenerationStats()
812 if (res?.data) {
813 set({ generatingCount: res.data.generatingCount || 0 })
814 }
815 }
816 catch {
817 // 静默失败
818 }
819 },
820
821 /**
822 * 轮询回调:更新生成中任务数量
823 */
824 updateGeneratingCount: (count: number) => {
825 set({ generatingCount: count })
826 },
827
828 /**
829 * 无感刷新素材列表(不触发 loading/骨架屏)
830 * 静默请求第1页数据,找出新增的草稿 prepend 到头部
831 */
832 silentRefreshMaterials: async (planId: string) => {
833 try {
834 const { materialsPagination, materials, materialsFilter } = get()
835 const res = await apiGetMaterialList(planId, 1, materialsPagination.pageSize, materialsFilter)
836 const resData = res?.data as { list?: any[], total?: number } | undefined
837 const freshList = (resData?.list || []) as PromotionMaterial[]
838 const total = resData?.total || 0
839
840 // 构建当前 materials 的 id Set
841 const existingIds = new Set(materials.map(m => m.id))
842 // 找出新增草稿
843 const newItems = freshList.filter(item => !existingIds.has(item.id))
844
845 if (newItems.length > 0) {
846 set({
847 materials: [...newItems, ...materials],
848 materialsPagination: {
849 ...materialsPagination,
850 total,
851 },
852 })
853 }
854 }
855 catch {
856 // 静默失败
857 }
858 },
859
860 /**
861 * 外部写入草稿数据(不发请求)
862 * 用于 mediaTabStore.fetchAllList 初始加载后同步草稿到 planDetailStore
863 */
864 setMaterialsFromExternal: (list: PromotionMaterial[], total: number, pageSize: number) => {
865 set({
866 materials: list,
867 materialsInitialized: true,
868 materialsLoading: false,
869 materialsPagination: {
870 current: 1,
871 pageSize,
872 total,
873 hasMore: list.length < total,
874 },
875 })
876 },
877
878 /**
879 * 外部同步新增草稿(不发请求,prepend 新增项)
880 * 用于 mediaTabStore.silentRefreshAll 后同步新增草稿
881 */
882 syncMaterialsFromFresh: (freshList: PromotionMaterial[], total: number) => {
883 const { materials, materialsPagination } = get()
884 const existingIds = new Set(materials.map(m => m.id))
885 const newItems = freshList.filter(item => !existingIds.has(item.id))
886
887 if (newItems.length > 0) {
888 set({
889 materials: [...newItems, ...materials],
890 materialsPagination: {
891 ...materialsPagination,
892 total,
893 },
894 })
895 }
896 },
897
898 /**
899 * 外部追加草稿(不发请求)
900 * 用于 mediaTabStore.loadMoreAll 加载更多草稿后同步
901 */
902 appendMaterials: (list: PromotionMaterial[], total: number) => {
903 const { materials, materialsPagination } = get()
904 // 去重后追加
905 const existingIds = new Set(materials.map(m => m.id))
906 const newItems = list.filter(item => !existingIds.has(item.id))
907 if (newItems.length > 0) {
908 set({
909 materials: [...materials, ...newItems],
910 materialsPagination: {
911 ...materialsPagination,
912 total,
913 hasMore: (materials.length + newItems.length) < total,
914 },
915 })
916 }
917 },
918
919 // ==================== 搜索/筛选 & 批量操作 ====================
920
921 setMaterialsFilter: (filter: MaterialListFilters) => {
922 const { currentPlan } = get()
923 set({
924 materialsFilter: filter,
925 materials: [],
926 materialsPagination: {
927 current: 1,
928 pageSize: 12,
929 total: 0,
930 hasMore: true,
931 },
932 })
933 if (currentPlan) {
934 methods.fetchMaterials(currentPlan.id, 1)
935 }
936 },
937
938 resetMaterialsFilter: () => {
939 methods.setMaterialsFilter({})
940 },
941
942 enterBatchMode: () => {
943 set({ batchMode: true, selectedMaterialIds: [] })
944 },
945
946 exitBatchMode: () => {
947 set({ batchMode: false, selectedMaterialIds: [] })
948 },
949
950 toggleMaterialSelection: (id: string) => {
951 const { selectedMaterialIds } = get()
952 const index = selectedMaterialIds.indexOf(id)
953 if (index === -1) {
954 set({ selectedMaterialIds: [...selectedMaterialIds, id] })
955 }
956 else {
957 set({ selectedMaterialIds: selectedMaterialIds.filter(i => i !== id) })
958 }
959 },
960
961 selectAllLoadedMaterials: () => {
962 const { materials } = get()
963 set({ selectedMaterialIds: materials.map(m => m.id) })
964 },
965
966 deselectAllMaterials: () => {
967 set({ selectedMaterialIds: [] })
968 },
969
970 batchDeleteMaterials: async () => {
971 const { selectedMaterialIds, currentPlan } = get()
972 if (selectedMaterialIds.length === 0 || !currentPlan)
973 return false
974 set({ batchDeleting: true })
975 try {
976 const res = await apiBatchDeleteMaterials(selectedMaterialIds)
977 if (res?.code !== 0)
978 return false
979 set({ batchMode: false, selectedMaterialIds: [] })
980 await methods.fetchMaterials(currentPlan.id, 1)
981 // 同步更新全部 Tab
982 removeDraftItemsFromMediaTabs(selectedMaterialIds)
983 return true
984 }
985 catch {
986 return false
987 }
988 finally {
989 set({ batchDeleting: false })
990 }
991 },
992
993 openConditionalDeleteDialog: () => {
994 set({ conditionalDeleteDialogOpen: true })
995 },
996
997 closeConditionalDeleteDialog: () => {
998 set({ conditionalDeleteDialogOpen: false })
999 },
1000
1001 filterDeleteMaterials: async (conditions: Omit<MaterialFilterDeleteParams, 'groupId'>) => {
1002 const { currentPlan } = get()
1003 if (!currentPlan)
1004 return false
1005 try {
1006 const res = await apiFilterDeleteMaterials({ ...conditions, groupId: currentPlan.id })
1007 if (res?.code !== 0)
1008 return false
1009 set({ conditionalDeleteDialogOpen: false })
1010 await methods.fetchMaterials(currentPlan.id, 1)
1011 // 条件删除无法确定删除了哪些 ID,重新拉取全部 Tab
1012 refreshDraftItemsInMediaTabs(currentPlan.id, currentPlan.id)
1013 return true
1014 }
1015 catch {
1016 return false
1017 }
1018 },
1019
1020 // ==================== 重置 ====================
1021
1022 reset: () => {
1023 set(getInitialState())
1024 },
1025
1026 /**
1027 * 初始化详情页数据
1028 * @param planId 计划 ID
1029 * @param force 是否强制重新加载(Tab 切换时使用)
1030 */
1031 initDetailPage: async (planId: string, force: boolean = false, options?: { source?: PublishRecordSource }) => {
1032 // 如果已经初始化过相同的 planId 且非强制刷新,跳过
1033 const { initializedPlanId } = get()
1034 if (!force && initializedPlanId === planId) {
1035 return
1036 }
1037
1038 // 重置状态
1039 set(getInitialState())
1040 // 标记正在初始化的 planId
1041 set({ initializedPlanId: planId, PublishRecordSource: options?.source })
1042
1043 // 并行加载数据
1044 await Promise.all([
1045 methods.fetchPlanDetail(planId),
1046 methods.fetchMaterials(planId, 1),
1047 methods.fetchStatistics(planId, options?.source),
1048 methods.fetchPublishRecords(planId, 1, options?.source),
1049 methods.fetchGenerationTasks(planId),
1050 ])
1051 },
1052
1053 /**
1054 * 仅加载「内容管理」所需数据
1055 * 加载: planDetail + materials + generatingStats
1056 */
1057 initContentData: async (planId: string, force: boolean = false, options?: { skipMaterials?: boolean }) => {
1058 const { initializedPlanId } = get()
1059 if (!force && initializedPlanId === planId) {
1060 return
1061 }
1062
1063 // 重置状态
1064 set(getInitialState())
1065 set({ initializedPlanId: planId })
1066
1067 if (options?.skipMaterials) {
1068 // 跳过素材加载时,将 loading 置为 false 避免骨架屏卡住
1069 set({ materialsLoading: false })
1070 await Promise.all([
1071 methods.fetchPlanDetail(planId),
1072 methods.fetchGenerationTasks(planId),
1073 ])
1074 }
1075 else {
1076 await Promise.all([
1077 methods.fetchPlanDetail(planId),
1078 methods.fetchMaterials(planId, 1),
1079 methods.fetchGenerationTasks(planId),
1080 ])
1081 }
1082 },
1083
1084 /**
1085 * 仅加载「数据分析」所需数据
1086 * 加载: statistics + publishRecords
1087 * planDetail 复用已加载的缓存
1088 */
1089 initAnalyticsData: async (planId: string, force: boolean = false, options?: { source?: PublishRecordSource }) => {
1090 const { analyticsInitialized } = get()
1091 if (!force && analyticsInitialized) {
1092 return
1093 }
1094
1095 set({ analyticsInitialized: true, PublishRecordSource: options?.source })
1096
1097 await Promise.all([
1098 methods.fetchStatistics(planId, options?.source),
1099 methods.fetchPublishRecords(planId, 1, options?.source),
1100 ])
1101 },
1102 }
1103
1104 return methods
1105 }),
1106 )
1107
1108 registerPlanDetailMaterialSyncAdapter({
1109 setMaterialsFromExternal: (list, total, pageSize) => {
1110 usePlanDetailStore.getState().setMaterialsFromExternal(list, total, pageSize)
1111 },
1112 syncMaterialsFromFresh: (freshList, total) => {
1113 usePlanDetailStore.getState().syncMaterialsFromFresh(freshList, total)
1114 },
1115 appendMaterials: (list, total) => {
1116 usePlanDetailStore.getState().appendMaterials(list, total)
1117 },
1118 isCurrentPlan: (planId) => {
1119 return usePlanDetailStore.getState().currentPlan?.id === planId
1120 },
1121 silentRefreshMaterials: (planId) => {
1122 return usePlanDetailStore.getState().silentRefreshMaterials(planId)
1123 },
1124 refreshCurrentMaterials: async () => {
1125 const store = usePlanDetailStore.getState()
1126 if (store.currentPlan) {
1127 await store.fetchMaterials(store.currentPlan.id, 1)
1128 }
1129 },
1130 })
1131
1131 lines TYPESCRIPT