返回 AiToEarn
1 /**
2 * TransferDraftDialog - 将草稿或媒体资源移动到其它草稿箱
3 * 复用草稿箱列表接口和 SearchableSelect 完成目标草稿箱选择
4 */
5
6 'use client'
7
8 import type { PromotionPlan } from '@/api/materials/material.types'
9 import { ArrowRightLeft, Loader2 } from 'lucide-react'
10 import { memo, useCallback, useEffect, useMemo, useState } from 'react'
11 import { useShallow } from 'zustand/react/shallow'
12 import { apiGetMaterialGroupList, apiTransferMaterials, transferMedia } from '@/api/materials/material.api'
13
14 import { useTransClient } from '@/app/i18n/client'
15 import { Button } from '@/components/ui/button'
16 import {
17 Dialog,
18 DialogContent,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogTitle,
23 } from '@/components/ui/dialog'
24 import { Label } from '@/components/ui/label'
25 import { SearchableSelect } from '@/components/ui/searchable-select'
26 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
27 import { useTransferDraftDialogStore } from '@/store/draft-box/transferDraftDialogStore'
28 import { toast } from '@/utils/ui/toast'
29 import { useMediaTabStore } from '../ContentTabs/mediaTabStore'
30
31 const PAGE_SIZE = 100
32
33 function isApiSuccessResponse(value: unknown): value is { code: number } {
34 return typeof value === 'object' && value !== null && 'code' in value && typeof value.code === 'number'
35 }
36
37 async function fetchAllDraftPlans() {
38 const plans: PromotionPlan[] = []
39 let page = 1
40 let total = 0
41
42 do {
43 const res = await apiGetMaterialGroupList(page, PAGE_SIZE)
44 const list = res?.data?.list || []
45 total = res?.data?.total || 0
46 plans.push(...list)
47
48 if (list.length < PAGE_SIZE) {
49 break
50 }
51
52 page += 1
53 } while (plans.length < total)
54
55 return plans
56 }
57
58 export const TransferDraftDialog = memo(() => {
59 const { t } = useTransClient('brandPromotion')
60 const { t: tCommon } = useTransClient('common')
61
62 const {
63 open,
64 currentPlanId,
65 draftIds,
66 mediaIds,
67 closeDialog,
68 } = useTransferDraftDialogStore(
69 useShallow(state => ({
70 open: state.open,
71 currentPlanId: state.currentPlanId,
72 draftIds: state.draftIds,
73 mediaIds: state.mediaIds,
74 closeDialog: state.closeDialog,
75 })),
76 )
77
78 const [plans, setPlans] = useState<PromotionPlan[]>([])
79 const [loading, setLoading] = useState(false)
80 const [submitting, setSubmitting] = useState(false)
81 const [targetPlanId, setTargetPlanId] = useState('')
82
83 const selectedCount = draftIds.length + mediaIds.length
84
85 const targetOptions = useMemo(() => {
86 return plans
87 .filter(plan => plan.id !== currentPlanId)
88 .map(plan => ({
89 value: plan.id,
90 label: plan.name || plan.title || '',
91 }))
92 }, [plans, currentPlanId])
93
94 const selectedTarget = useMemo(() => {
95 return targetOptions.find(option => option.value === targetPlanId)
96 }, [targetOptions, targetPlanId])
97
98 const loadPlans = useCallback(async () => {
99 setLoading(true)
100 try {
101 const list = await fetchAllDraftPlans()
102 setPlans(list)
103 }
104 catch {
105 setPlans([])
106 }
107 finally {
108 setLoading(false)
109 }
110 }, [])
111
112 useEffect(() => {
113 if (!open) {
114 setTargetPlanId('')
115 return
116 }
117
118 setTargetPlanId('')
119 loadPlans()
120 }, [open, loadPlans])
121
122 useEffect(() => {
123 if (targetOptions.length === 1) {
124 setTargetPlanId(targetOptions[0].value)
125 }
126 }, [targetOptions])
127
128 const handleConfirm = useCallback(async () => {
129 if (!currentPlanId || !targetPlanId || submitting) {
130 return
131 }
132
133 setSubmitting(true)
134
135 try {
136 const transferTasks: Promise<unknown>[] = []
137
138 if (draftIds.length > 0) {
139 transferTasks.push(
140 apiTransferMaterials({
141 ids: draftIds,
142 targetGroupId: targetPlanId,
143 mode: 'move',
144 }),
145 )
146 }
147
148 if (mediaIds.length > 0) {
149 transferTasks.push(
150 transferMedia({
151 ids: mediaIds,
152 targetGroupId: targetPlanId,
153 mode: 'move',
154 }),
155 )
156 }
157
158 const settledResults = await Promise.allSettled(transferTasks)
159 const allSucceeded = settledResults.every((result) => {
160 return result.status === 'fulfilled' && isApiSuccessResponse(result.value) && result.value.code === 0
161 })
162
163 const planStore = usePlanDetailStore.getState()
164 const mediaStore = useMediaTabStore.getState()
165
166 planStore.exitBatchMode()
167 mediaStore.exitBatchMode()
168 closeDialog()
169
170 const refreshTasks: Promise<unknown>[] = []
171
172 if (draftIds.length > 0 && planStore.currentPlan?.id === currentPlanId) {
173 refreshTasks.push(planStore.fetchMaterials(currentPlanId, 1))
174 }
175
176 if (mediaIds.length > 0) {
177 if (mediaStore.video.initialized) {
178 refreshTasks.push(mediaStore.fetchMediaList(currentPlanId, 'video'))
179 }
180 if (mediaStore.img.initialized) {
181 refreshTasks.push(mediaStore.fetchMediaList(currentPlanId, 'img'))
182 }
183 }
184
185 if (mediaStore.all.initialized) {
186 refreshTasks.push(mediaStore.fetchAllList(currentPlanId, currentPlanId))
187 }
188
189 await Promise.all(refreshTasks)
190
191 if (allSucceeded) {
192 toast.success(
193 selectedTarget
194 ? t('draftManage.transferSuccessWithTarget', { name: selectedTarget.label })
195 : t('draftManage.transferSuccess'),
196 )
197 }
198 else {
199 toast.error(t('draftManage.transferFailed'))
200 }
201 }
202 catch {
203 usePlanDetailStore.getState().exitBatchMode()
204 useMediaTabStore.getState().exitBatchMode()
205 closeDialog()
206 toast.error(t('draftManage.transferFailed'))
207 }
208 finally {
209 setSubmitting(false)
210 }
211 }, [closeDialog, currentPlanId, draftIds, mediaIds, selectedTarget, submitting, t, targetPlanId])
212
213 if (!open) {
214 return null
215 }
216
217 return (
218 <Dialog
219 open
220 onOpenChange={(nextOpen) => {
221 if (!nextOpen && !submitting) {
222 closeDialog()
223 }
224 }}
225 >
226 <DialogContent className="max-w-[440px]">
227 <DialogHeader>
228 <DialogTitle className="flex items-center gap-2">
229 <ArrowRightLeft className="h-4 w-4" />
230 {t('draftManage.transferTitle')}
231 </DialogTitle>
232 <DialogDescription>
233 {t('draftManage.transferDescription', { count: selectedCount })}
234 </DialogDescription>
235 </DialogHeader>
236
237 <div className="space-y-4 py-2">
238 <div className="space-y-2">
239 <Label>{t('draftManage.targetPlanLabel')}</Label>
240
241 {loading
242 ? (
243 <div className="flex h-10 items-center justify-center rounded-md border border-border bg-muted/20">
244 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
245 </div>
246 )
247 : targetOptions.length > 0
248 ? (
249 <SearchableSelect
250 options={targetOptions}
251 value={targetPlanId}
252 onValueChange={setTargetPlanId}
253 placeholder={t('draftManage.targetPlanPlaceholder')}
254 searchPlaceholder={t('draftManage.targetPlanPlaceholder')}
255 emptyText={t('draftManage.targetPlanEmpty')}
256 triggerClassName="h-10"
257 />
258 )
259 : (
260 <div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
261 {t('draftManage.transferNoAvailableTarget')}
262 </div>
263 )}
264 </div>
265 </div>
266
267 <DialogFooter>
268 <Button
269 variant="ghost"
270 onClick={closeDialog}
271 disabled={submitting}
272 className="cursor-pointer"
273 >
274 {tCommon('cancel')}
275 </Button>
276 <Button
277 onClick={handleConfirm}
278 disabled={!targetPlanId || targetOptions.length === 0 || submitting}
279 className="cursor-pointer gap-1.5"
280 >
281 {submitting && <Loader2 className="h-4 w-4 animate-spin" />}
282 {t('draftManage.transferConfirm')}
283 </Button>
284 </DialogFooter>
285 </DialogContent>
286 </Dialog>
287 )
288 })
289
290 TransferDraftDialog.displayName = 'TransferDraftDialog'
291
291 lines Plain Text