返回 AiToEarn
DraftBoxCore.tsx
根目录 / project / aitoearn-web / src / app / [lng] / draft-box / DraftBoxCore.tsx
1 /**
2 * 草稿箱核心组件
3 * 通过 PlanTabBar 管理多草稿箱切换,展示内容管理模块
4 */
5
6 'use client'
7
8 import { Loader2, Plus, Sparkles } from 'lucide-react'
9 import { usePathname, useSearchParams } from 'next/navigation'
10 import { useCallback, useEffect, useRef } from 'react'
11 import { useShallow } from 'zustand/react/shallow'
12 import { useTransClient } from '@/app/i18n/client'
13 import CreatePlanModal from '@/components/draft-box/components/CreatePlanModal'
14 import DraftContentModule from '@/components/draft-box/components/DraftContentModule'
15 import PlanTabBar from '@/components/draft-box/components/PlanTabBar'
16 import { Button } from '@/components/ui/button'
17 import { useBrandPromotionStore } from '@/store/draft-box/brandPromotionStore'
18 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
19 import { usePlanTabStore } from '@/store/draft-box/planTabStore'
20
21 export default function DraftBoxCore() {
22 const { t } = useTransClient('brandPromotion')
23 const pathname = usePathname()
24 const searchParams = useSearchParams()
25 const searchParamsString = searchParams.toString()
26 const urlPlanId = searchParams.get('planId')
27
28 const {
29 tabPlans,
30 selectedPlanId,
31 initialized,
32 planTabHydrated,
33 } = usePlanTabStore(
34 useShallow(state => ({
35 tabPlans: state.tabPlans,
36 selectedPlanId: state.selectedPlanId,
37 initialized: state.initialized,
38 planTabHydrated: state._hasHydrated,
39 })),
40 )
41
42 const initTabs = usePlanTabStore(state => state.initTabs)
43
44 const openCreatePlanModal = useBrandPromotionStore(
45 state => state.openCreatePlanModal,
46 )
47
48 const initContentData = usePlanDetailStore(state => state.initContentData)
49 const urlPlanExists = !!urlPlanId && tabPlans.some(plan => plan.id === urlPlanId)
50 const selectedPlanExists = !!selectedPlanId && tabPlans.some(plan => plan.id === selectedPlanId)
51 const initialUrlPlanProcessedRef = useRef(false)
52 const prevSelectedPlanIdRef = useRef<string | null>(selectedPlanId)
53 const prevUrlPlanIdRef = useRef<string | null>(urlPlanId)
54 const selectedPlanChanged = prevSelectedPlanIdRef.current !== selectedPlanId
55 const urlPlanChanged = prevUrlPlanIdRef.current !== urlPlanId
56 const shouldApplyUrlPlan = !!(
57 planTabHydrated
58 && initialized
59 && urlPlanId
60 && urlPlanExists
61 && selectedPlanId !== urlPlanId
62 && (!initialUrlPlanProcessedRef.current || urlPlanChanged)
63 )
64 const shouldSyncSelectedToUrl = !!(
65 planTabHydrated
66 && initialized
67 && selectedPlanId
68 && selectedPlanExists
69 && (
70 !urlPlanId
71 || !urlPlanExists
72 || (
73 selectedPlanId !== urlPlanId
74 && initialUrlPlanProcessedRef.current
75 && selectedPlanChanged
76 && !urlPlanChanged
77 )
78 )
79 )
80
81 const buildPlanUrl = useCallback((planId: string) => {
82 const params = new URLSearchParams(searchParamsString)
83 params.set('planId', planId)
84 const query = params.toString()
85 return query ? `${pathname}?${query}` : pathname
86 }, [pathname, searchParamsString])
87
88 // 初始化 Tab 列表
89 useEffect(() => {
90 if (!planTabHydrated) {
91 return
92 }
93
94 void initTabs(urlPlanId).finally(() => {
95 const state = usePlanTabStore.getState()
96 })
97 }, [initTabs, planTabHydrated, urlPlanId])
98
99 // URL 参数激活对应 Tab
100 useEffect(() => {
101 if (shouldApplyUrlPlan && urlPlanId) {
102 usePlanTabStore.getState().selectPlan(urlPlanId)
103 }
104 }, [shouldApplyUrlPlan, urlPlanId])
105
106 // 仅在当前选中由 store 驱动变化时回写 URL;URL 驱动切换时不反向覆盖,避免互相打架
107 useEffect(() => {
108 if (!selectedPlanId || !shouldSyncSelectedToUrl) {
109 return
110 }
111
112 const nextUrl = buildPlanUrl(selectedPlanId)
113 const currentUrl = `${window.location.pathname}${window.location.search}`
114 if (nextUrl !== currentUrl) {
115 window.history.replaceState(null, '', nextUrl)
116 }
117 }, [buildPlanUrl, selectedPlanId, shouldSyncSelectedToUrl])
118
119 // 仅在 URL 已经完成应用,或当前就是 store 主导的切换时初始化,避免首屏默认计划与 URL 计划双请求
120 useEffect(() => {
121 if (selectedPlanId && !shouldApplyUrlPlan) {
122 void initContentData(selectedPlanId, false, { skipMaterials: true }).finally(() => {
123 })
124 }
125 }, [initContentData, selectedPlanId, shouldApplyUrlPlan])
126
127 useEffect(() => {
128 if (planTabHydrated && initialized) {
129 initialUrlPlanProcessedRef.current = true
130 }
131 prevSelectedPlanIdRef.current = selectedPlanId
132 prevUrlPlanIdRef.current = urlPlanId
133 }, [initialized, planTabHydrated, selectedPlanId, urlPlanId])
134
135 // Tab 切换回调
136 const handlePlanChange = useCallback((planId: string) => {
137 void initContentData(planId, true, { skipMaterials: true }).finally(() => {
138 })
139 }, [initContentData])
140
141 const loading = !planTabHydrated || !initialized
142 const showEmpty = initialized && tabPlans.length === 0
143
144 if (loading) {
145 return (
146 <div className="flex flex-col h-full">
147 <div className="flex-1 min-h-0">
148 <div className="flex flex-col h-full bg-background">
149 <div className="flex-1 p-4 md:p-6">
150 <div className="flex items-center justify-center h-full">
151 <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
152 </div>
153 </div>
154 </div>
155 </div>
156 </div>
157 )
158 }
159
160 if (showEmpty) {
161 return (
162 <div className="flex flex-col h-full">
163 <div className="flex-1 min-h-0">
164 <div className="flex flex-col h-full bg-background">
165 <div className="flex-1 flex items-center justify-center p-4">
166 <div className="text-center max-w-md">
167 <div className="mx-auto w-20 h-20 rounded-full bg-gradient-to-br from-[#c565ef]/10 to-[#55D9ED]/10 flex items-center justify-center mb-6">
168 <Sparkles className="h-10 w-10 text-foreground/60" />
169 </div>
170 <h2 className="text-xl font-semibold text-foreground mb-2">
171 {t('empty.title')}
172 </h2>
173 <p className="text-muted-foreground mb-6">
174 {t('empty.description')}
175 </p>
176 <Button
177 size="lg"
178 className="cursor-pointer gap-2"
179 onClick={openCreatePlanModal}
180 >
181 <Plus className="h-5 w-5" />
182 {t('empty.createButton')}
183 </Button>
184 </div>
185 </div>
186 </div>
187 </div>
188 <CreatePlanModal />
189 </div>
190 )
191 }
192
193 return (
194 <div className="flex flex-col h-full">
195 {/* Tab 栏 */}
196 <div data-testid="draftbox-plan-tabs">
197 <PlanTabBar onPlanChange={handlePlanChange} />
198 </div>
199 <div className="flex-1 min-h-0">
200 <div className="flex flex-col h-full bg-background">
201 <div id="draft-box-scroll-content" className="flex-1 overflow-auto">
202 <DraftContentModule />
203 </div>
204 </div>
205 </div>
206 {/* 创建/编辑草稿箱弹窗 */}
207 <CreatePlanModal />
208 </div>
209 )
210 }
211
211 lines Plain Text