| 1 | /** |
| 2 | * ConditionalDeleteDialog - 按条件删除草稿弹窗 |
| 3 | * 外层控制渲染,内层使用 hooks(避免 useTransClient 动态加载闪烁) |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { MaterialListFilters } from '@/api/materials/material.types' |
| 9 | import lodash from 'lodash' |
| 10 | import { Loader2 } from 'lucide-react' |
| 11 | import { memo, useCallback, useEffect, useMemo, useState } from 'react' |
| 12 | import { useShallow } from 'zustand/react/shallow' |
| 13 | import { apiGetMaterialList } from '@/api/materials/material.api' |
| 14 | import { useTransClient } from '@/app/i18n/client' |
| 15 | import { Button } from '@/components/ui/button' |
| 16 | import { |
| 17 | Dialog, |
| 18 | DialogContent, |
| 19 | DialogFooter, |
| 20 | DialogHeader, |
| 21 | DialogTitle, |
| 22 | } from '@/components/ui/dialog' |
| 23 | import { Input } from '@/components/ui/input' |
| 24 | import { Label } from '@/components/ui/label' |
| 25 | import { NumberInput } from '@/components/ui/number-input' |
| 26 | import { usePlanDetailStore } from '@/store/draft-box/planDetailStore' |
| 27 | import { toast } from '@/utils/ui/toast' |
| 28 | |
| 29 | // 外层:控制渲染时机 |
| 30 | const ConditionalDeleteDialog = memo(() => { |
| 31 | const { open, closeDialog } = usePlanDetailStore( |
| 32 | useShallow(state => ({ |
| 33 | open: state.conditionalDeleteDialogOpen, |
| 34 | closeDialog: state.closeConditionalDeleteDialog, |
| 35 | })), |
| 36 | ) |
| 37 | |
| 38 | if (!open) |
| 39 | return null |
| 40 | |
| 41 | return ( |
| 42 | <ConditionalDeleteDialogContent onOpenChange={(v) => { |
| 43 | if (!v) |
| 44 | closeDialog() |
| 45 | }} |
| 46 | /> |
| 47 | ) |
| 48 | }) |
| 49 | |
| 50 | ConditionalDeleteDialog.displayName = 'ConditionalDeleteDialog' |
| 51 | |
| 52 | // 内层:使用 hooks |
| 53 | const ConditionalDeleteDialogContent = memo(({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => { |
| 54 | const { t } = useTransClient('brandPromotion') |
| 55 | |
| 56 | const { currentPlan, filterDeleteMaterials } = usePlanDetailStore( |
| 57 | useShallow(state => ({ |
| 58 | currentPlan: state.currentPlan, |
| 59 | filterDeleteMaterials: state.filterDeleteMaterials, |
| 60 | })), |
| 61 | ) |
| 62 | |
| 63 | const [title, setTitle] = useState('') |
| 64 | const [useCount, setUseCount] = useState<number | undefined>() |
| 65 | const [matchCount, setMatchCount] = useState<number | null>(null) |
| 66 | const [querying, setQuerying] = useState(false) |
| 67 | const [deleting, setDeleting] = useState(false) |
| 68 | |
| 69 | const hasCondition = title.trim() !== '' || useCount !== undefined |
| 70 | |
| 71 | // 构建筛选条件 |
| 72 | const buildFilters = useCallback((): MaterialListFilters => { |
| 73 | const filters: MaterialListFilters = {} |
| 74 | if (title.trim()) |
| 75 | filters.title = title.trim() |
| 76 | if (useCount !== undefined) |
| 77 | filters.useCount = useCount |
| 78 | return filters |
| 79 | }, [title, useCount]) |
| 80 | |
| 81 | // debounce 查询匹配数量 |
| 82 | const queryMatchCount = useMemo( |
| 83 | () => lodash.debounce(async (groupId: string, filters: MaterialListFilters) => { |
| 84 | if (!filters.title && filters.useCount === undefined) { |
| 85 | setMatchCount(null) |
| 86 | setQuerying(false) |
| 87 | return |
| 88 | } |
| 89 | setQuerying(true) |
| 90 | try { |
| 91 | const res = await apiGetMaterialList(groupId, 1, 0, filters) |
| 92 | setMatchCount(res?.data?.total ?? 0) |
| 93 | } |
| 94 | catch { |
| 95 | setMatchCount(null) |
| 96 | } |
| 97 | finally { |
| 98 | setQuerying(false) |
| 99 | } |
| 100 | }, 500), |
| 101 | [], |
| 102 | ) |
| 103 | |
| 104 | useEffect(() => { |
| 105 | if (!currentPlan) |
| 106 | return |
| 107 | const filters = buildFilters() |
| 108 | if (!filters.title && filters.useCount === undefined) { |
| 109 | setMatchCount(null) |
| 110 | return |
| 111 | } |
| 112 | setQuerying(true) |
| 113 | queryMatchCount(currentPlan.id, filters) |
| 114 | }, [title, useCount, currentPlan, buildFilters, queryMatchCount]) |
| 115 | |
| 116 | // 清理 debounce |
| 117 | useEffect(() => { |
| 118 | return () => { |
| 119 | queryMatchCount.cancel() |
| 120 | } |
| 121 | }, [queryMatchCount]) |
| 122 | |
| 123 | const handleDelete = useCallback(async () => { |
| 124 | if (!hasCondition || matchCount === 0) |
| 125 | return |
| 126 | setDeleting(true) |
| 127 | try { |
| 128 | const conditions = buildFilters() |
| 129 | const success = await filterDeleteMaterials(conditions) |
| 130 | if (success) { |
| 131 | toast.success(t('draftManage.conditionalDeleteSuccess')) |
| 132 | } |
| 133 | else { |
| 134 | toast.error(t('draftManage.conditionalDeleteFailed')) |
| 135 | } |
| 136 | } |
| 137 | finally { |
| 138 | setDeleting(false) |
| 139 | } |
| 140 | }, [hasCondition, matchCount, buildFilters, filterDeleteMaterials, t]) |
| 141 | |
| 142 | const deleteDisabled = !hasCondition || matchCount === 0 || matchCount === null || deleting |
| 143 | |
| 144 | return ( |
| 145 | <Dialog open onOpenChange={onOpenChange}> |
| 146 | <DialogContent data-testid="draftbox-cond-delete-dialog" className="max-w-[420px]"> |
| 147 | <DialogHeader> |
| 148 | <DialogTitle>{t('draftManage.conditionalDeleteTitle')}</DialogTitle> |
| 149 | </DialogHeader> |
| 150 | |
| 151 | <div className="space-y-4 py-2"> |
| 152 | <div className="space-y-2"> |
| 153 | <Label>{t('draftManage.conditionTitle')}</Label> |
| 154 | <Input |
| 155 | data-testid="draftbox-cond-title-input" |
| 156 | value={title} |
| 157 | onChange={e => setTitle(e.target.value)} |
| 158 | placeholder={t('draftManage.conditionTitlePlaceholder')} |
| 159 | /> |
| 160 | </div> |
| 161 | |
| 162 | <div className="space-y-2"> |
| 163 | <Label>{t('draftManage.conditionUseCount')}</Label> |
| 164 | <NumberInput |
| 165 | data-testid="draftbox-cond-usecount-input" |
| 166 | value={useCount} |
| 167 | onValueChange={v => setUseCount(v)} |
| 168 | decimalScale={0} |
| 169 | allowNegative={false} |
| 170 | placeholder={t('draftManage.conditionUseCountPlaceholder')} |
| 171 | /> |
| 172 | </div> |
| 173 | |
| 174 | <div data-testid="draftbox-cond-match-count" className="rounded-md bg-muted p-3 text-sm"> |
| 175 | {!hasCondition && ( |
| 176 | <span className="text-muted-foreground">{t('draftManage.setConditionHint')}</span> |
| 177 | )} |
| 178 | {hasCondition && querying && ( |
| 179 | <span className="text-muted-foreground flex items-center gap-2"> |
| 180 | <Loader2 className="h-3.5 w-3.5 animate-spin" /> |
| 181 | {t('draftManage.matchCountLoading')} |
| 182 | </span> |
| 183 | )} |
| 184 | {hasCondition && !querying && matchCount !== null && ( |
| 185 | <span className={matchCount > 0 ? 'text-destructive font-medium' : 'text-muted-foreground'}> |
| 186 | {t('draftManage.matchCount', { count: matchCount })} |
| 187 | </span> |
| 188 | )} |
| 189 | </div> |
| 190 | </div> |
| 191 | |
| 192 | <DialogFooter> |
| 193 | <Button variant="ghost" onClick={() => onOpenChange(false)} className="cursor-pointer"> |
| 194 | {t('draftManage.cancel')} |
| 195 | </Button> |
| 196 | <Button |
| 197 | data-testid="draftbox-cond-delete-confirm-btn" |
| 198 | variant="destructive" |
| 199 | onClick={handleDelete} |
| 200 | disabled={deleteDisabled} |
| 201 | className="cursor-pointer gap-1.5" |
| 202 | > |
| 203 | {deleting && <Loader2 className="h-3.5 w-3.5 animate-spin" />} |
| 204 | {t('common.delete')} |
| 205 | {matchCount !== null && matchCount > 0 && ` (${matchCount})`} |
| 206 | </Button> |
| 207 | </DialogFooter> |
| 208 | </DialogContent> |
| 209 | </Dialog> |
| 210 | ) |
| 211 | }) |
| 212 | |
| 213 | ConditionalDeleteDialogContent.displayName = 'ConditionalDeleteDialogContent' |
| 214 | |
| 215 | export { ConditionalDeleteDialog } |
| 216 |