返回 AiToEarn
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
245 lines Plain Text