| 1 | /** |
| 2 | * MorePanel - 更多草稿箱面板 |
| 3 | * 移动端: Dialog 底部弹出 |
| 4 | * PC 端: Popover 下拉 |
| 5 | * 滚动到底部自动加载更多(IntersectionObserver) |
| 6 | */ |
| 7 | |
| 8 | 'use client' |
| 9 | |
| 10 | import type { PromotionPlan } from '@/api/materials/material.types' |
| 11 | import { Loader2 } from 'lucide-react' |
| 12 | import { useEffect, useRef } from 'react' |
| 13 | import { useShallow } from 'zustand/react/shallow' |
| 14 | import { useTransClient } from '@/app/i18n/client' |
| 15 | import { |
| 16 | Dialog, |
| 17 | DialogContent, |
| 18 | DialogHeader, |
| 19 | DialogTitle, |
| 20 | } from '@/components/ui/dialog' |
| 21 | import { |
| 22 | Popover, |
| 23 | PopoverContent, |
| 24 | PopoverTrigger, |
| 25 | } from '@/components/ui/popover' |
| 26 | import { useIsMobile } from '@/hooks/useIsMobile' |
| 27 | import { useBrandPromotionStore } from '@/store/draft-box/brandPromotionStore' |
| 28 | import { usePlanTabStore } from '@/store/draft-box/planTabStore' |
| 29 | import confirm from '@/utils/ui/confirm' |
| 30 | import PlanListItem from './PlanListItem' |
| 31 | |
| 32 | interface MorePanelProps { |
| 33 | trigger: React.ReactNode |
| 34 | onPlanChange?: (planId: string) => void |
| 35 | } |
| 36 | |
| 37 | function MorePanel({ trigger, onPlanChange }: MorePanelProps) { |
| 38 | const { t } = useTransClient('brandPromotion') |
| 39 | const isMobile = useIsMobile() |
| 40 | |
| 41 | const { |
| 42 | morePlans, |
| 43 | morePlansLoading, |
| 44 | morePlansPagination, |
| 45 | morePanelOpen, |
| 46 | selectedPlanId, |
| 47 | openMorePanel, |
| 48 | closeMorePanel, |
| 49 | selectPlan, |
| 50 | loadMoreInPanel, |
| 51 | onPlanDeleted, |
| 52 | } = usePlanTabStore( |
| 53 | useShallow(state => ({ |
| 54 | morePlans: state.morePlans, |
| 55 | morePlansLoading: state.morePlansLoading, |
| 56 | morePlansPagination: state.morePlansPagination, |
| 57 | morePanelOpen: state.morePanelOpen, |
| 58 | selectedPlanId: state.selectedPlanId, |
| 59 | openMorePanel: state.openMorePanel, |
| 60 | closeMorePanel: state.closeMorePanel, |
| 61 | selectPlan: state.selectPlan, |
| 62 | loadMoreInPanel: state.loadMoreInPanel, |
| 63 | onPlanDeleted: state.onPlanDeleted, |
| 64 | })), |
| 65 | ) |
| 66 | |
| 67 | const { |
| 68 | openEditPlanModal, |
| 69 | deletePlan, |
| 70 | } = useBrandPromotionStore( |
| 71 | useShallow(state => ({ |
| 72 | openEditPlanModal: state.openEditPlanModal, |
| 73 | deletePlan: state.deletePlan, |
| 74 | })), |
| 75 | ) |
| 76 | |
| 77 | const sentinelRef = useRef<HTMLDivElement>(null) |
| 78 | |
| 79 | useEffect(() => { |
| 80 | if (!morePanelOpen) |
| 81 | return |
| 82 | |
| 83 | const sentinel = sentinelRef.current |
| 84 | if (!sentinel) |
| 85 | return |
| 86 | |
| 87 | const observer = new IntersectionObserver( |
| 88 | ([entry]) => { |
| 89 | if (entry.isIntersecting) { |
| 90 | loadMoreInPanel() |
| 91 | } |
| 92 | }, |
| 93 | { threshold: 0 }, |
| 94 | ) |
| 95 | |
| 96 | observer.observe(sentinel) |
| 97 | return () => observer.disconnect() |
| 98 | }, [morePanelOpen, morePlansLoading, morePlansPagination.hasMore, loadMoreInPanel]) |
| 99 | |
| 100 | const handleSelect = (planId: string) => { |
| 101 | if (planId === selectedPlanId) { |
| 102 | closeMorePanel() |
| 103 | return |
| 104 | } |
| 105 | selectPlan(planId) |
| 106 | onPlanChange?.(planId) |
| 107 | } |
| 108 | |
| 109 | const handleEdit = (plan: PromotionPlan) => { |
| 110 | closeMorePanel() |
| 111 | openEditPlanModal(plan) |
| 112 | } |
| 113 | |
| 114 | const handleDelete = (plan: PromotionPlan) => { |
| 115 | confirm({ |
| 116 | title: t('plan.deleteConfirmTitle'), |
| 117 | content: t('plan.deleteConfirmDesc', { name: plan.name || plan.title }), |
| 118 | okType: 'destructive', |
| 119 | onOk: async () => { |
| 120 | const success = await deletePlan(plan.id) |
| 121 | if (success) { |
| 122 | onPlanDeleted(plan.id) |
| 123 | } |
| 124 | }, |
| 125 | }) |
| 126 | } |
| 127 | |
| 128 | const panelContent = ( |
| 129 | <div className="flex flex-col"> |
| 130 | {/* 标题行 */} |
| 131 | <div className="flex items-center justify-between mb-3"> |
| 132 | <span className="text-sm font-medium">{t('planTab.moreTitle')}</span> |
| 133 | <span className="text-xs text-muted-foreground"> |
| 134 | {t('planTab.totalCount', { count: morePlansPagination.total })} |
| 135 | </span> |
| 136 | </div> |
| 137 | |
| 138 | {/* 列表 */} |
| 139 | <div className="space-y-1 max-h-[50vh] overflow-y-auto"> |
| 140 | {morePlans.map(plan => ( |
| 141 | <PlanListItem |
| 142 | key={plan.id} |
| 143 | plan={plan} |
| 144 | isSelected={plan.id === selectedPlanId} |
| 145 | onSelect={handleSelect} |
| 146 | onEdit={handleEdit} |
| 147 | onDelete={handleDelete} |
| 148 | /> |
| 149 | ))} |
| 150 | |
| 151 | {morePlansLoading && ( |
| 152 | <div className="flex justify-center py-3"> |
| 153 | <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> |
| 154 | </div> |
| 155 | )} |
| 156 | |
| 157 | {!morePlansPagination.hasMore && morePlans.length > 0 && ( |
| 158 | <div className="text-center text-xs text-muted-foreground py-3"> |
| 159 | {t('planTab.noMore')} |
| 160 | </div> |
| 161 | )} |
| 162 | |
| 163 | <div ref={sentinelRef} className="h-1" /> |
| 164 | </div> |
| 165 | </div> |
| 166 | ) |
| 167 | |
| 168 | if (isMobile) { |
| 169 | return ( |
| 170 | <> |
| 171 | <div onClick={openMorePanel}> |
| 172 | {trigger} |
| 173 | </div> |
| 174 | {morePanelOpen && ( |
| 175 | <Dialog |
| 176 | open={morePanelOpen} |
| 177 | onOpenChange={(open) => { |
| 178 | if (!open) |
| 179 | closeMorePanel() |
| 180 | }} |
| 181 | > |
| 182 | <DialogContent |
| 183 | className="fixed bottom-0 top-auto left-0 right-0 translate-x-0 translate-y-0 rounded-t-xl rounded-b-none w-full sm:w-full max-h-[70vh]" |
| 184 | hideCloseButton |
| 185 | > |
| 186 | <DialogHeader> |
| 187 | <DialogTitle>{t('planTab.moreTitle')}</DialogTitle> |
| 188 | </DialogHeader> |
| 189 | <div className="flex items-center justify-between mb-1"> |
| 190 | <span className="text-xs text-muted-foreground"> |
| 191 | {t('planTab.totalCount', { count: morePlansPagination.total })} |
| 192 | </span> |
| 193 | </div> |
| 194 | <div className="space-y-1 max-h-[50vh] overflow-y-auto"> |
| 195 | {morePlans.map(plan => ( |
| 196 | <PlanListItem |
| 197 | key={plan.id} |
| 198 | plan={plan} |
| 199 | isSelected={plan.id === selectedPlanId} |
| 200 | onSelect={handleSelect} |
| 201 | onEdit={handleEdit} |
| 202 | onDelete={handleDelete} |
| 203 | /> |
| 204 | ))} |
| 205 | {morePlansLoading && ( |
| 206 | <div className="flex justify-center py-3"> |
| 207 | <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> |
| 208 | </div> |
| 209 | )} |
| 210 | |
| 211 | {!morePlansPagination.hasMore && morePlans.length > 0 && ( |
| 212 | <div className="text-center text-xs text-muted-foreground py-3"> |
| 213 | {t('planTab.noMore')} |
| 214 | </div> |
| 215 | )} |
| 216 | |
| 217 | <div ref={sentinelRef} className="h-1" /> |
| 218 | </div> |
| 219 | </DialogContent> |
| 220 | </Dialog> |
| 221 | )} |
| 222 | </> |
| 223 | ) |
| 224 | } |
| 225 | |
| 226 | return ( |
| 227 | <Popover |
| 228 | open={morePanelOpen} |
| 229 | onOpenChange={(open) => { |
| 230 | if (open) |
| 231 | openMorePanel(); else closeMorePanel() |
| 232 | }} |
| 233 | > |
| 234 | <PopoverTrigger asChild> |
| 235 | {trigger} |
| 236 | </PopoverTrigger> |
| 237 | <PopoverContent align="end" className="w-80" allowInnerScroll> |
| 238 | {panelContent} |
| 239 | </PopoverContent> |
| 240 | </Popover> |
| 241 | ) |
| 242 | } |
| 243 | |
| 244 | export default MorePanel |
| 245 |