| 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 |