import { useEffect, useState } from "react"; import { ArrowDown, ArrowUp, ChevronRight, Eye, Plus, RefreshCcw, Save, Send, Trash2, X, } from "lucide-react"; import { createSiteConfigItem, deleteSiteConfigItem, getProducts, getSiteConfig, publishSite, reorderSiteConfigItems, resetGuizhouContent, updateSiteConfigItem, } from "@/api"; import type { Product, SiteConfig, SiteItemPatch, SiteModule } from "@/api"; import { AdminDisclosure } from "@/components/admin/AdminDisclosure"; import { EmptyState } from "@/components/admin/EmptyState"; import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSectionsPanel"; import { SingleImageUploader } from "@/components/admin/SingleImageUploader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { compactSiteItemPayload, createEmptySiteItemDraft, itemImage, itemMeta, itemName, itemSortOrder, itemTags, moduleEditable, moduleItems, siteItemPrimaryKey, } from "@/lib/admin-utils"; import type { DirtyChangeHandler, EditableSiteItem, ModuleId, Notify, PageId, Tab, } from "@/types/admin"; const NEW_SITE_ITEM_ID = "__new_site_item__"; const moreServicesTargetOptions = [ { value: "", label: "按默认顺序" }, { value: "cardBenefits", label: "万趣权益页" }, { value: "service", label: "服务管家" }, { value: "destinationPicker", label: "目的地页" }, { value: "demand", label: "提交需求" }, ] as const; const pageSpecs: { id: PageId; title: string; subtitle: string; frontPath: string; modules: { id: ModuleId; label: string; hint: string; frontPosition: string; }[]; }[] = [ { id: "home", title: "万趣首页", subtitle: "维护用户进入小程序后第一屏到页尾的内容顺序。", frontPath: "前台:首页 / bottom tab「万趣」", modules: [ { id: "heroSlides", label: "顶部轮播", hint: "控制首屏大图、活动入口和主视觉顺序。", frontPosition: "首页第一屏", }, { id: "destinations", label: "探索贵州", hint: "控制省内目的地宫格、热门标记和搜索入口。", frontPosition: "首页「探索贵州」", }, { id: "map", label: "贵州地图", hint: "展示贵州省内的地图信息和地点。", frontPosition: "首页「探索贵州」", }, { id: "themes", label: "主题甄选", hint: "控制横向主题卡和搜索跳转。", frontPosition: "首页「主题甄选」", }, { id: "campaigns", label: "特价优惠", hint: "维护活动标题、封面和发布状态,供首页特价优惠入口使用。", frontPosition: "首页「特价优惠」", }, { id: "routeSections", label: "精选线路", hint: "按运营任务新增精选线路分组,维护标题、副文案、启用状态和关联线路顺序。", frontPosition: "首页精选线路", }, { id: "hotelGroups", label: "特色酒店", hint: "维护首页特色酒店卡片标题、描述、封面图、启用状态和排序。", frontPosition: "首页「特色酒店」", }, { id: "vehicleOptions", label: "万趣用车", hint: "维护首页用车卡片标题、描述、封面图、启用状态和排序。", frontPosition: "首页「万趣用车」", }, { id: "ctaBanners", label: "更多服务", hint: "维护更多服务卡片标题、背景图、启用状态和排序。", frontPosition: "首页「更多服务」", }, ], }, { id: "destination", title: "目的地页", subtitle: "按前台目的地页模块核对数据来源和维护入口。", frontPath: "前台:bottom tab「目的地」/ 搜索弹层", modules: [ { id: "destinationHero", label: "顶部主视觉", hint: "对应目的地页顶部大图和“贵州小包团目的地”文案。", frontPosition: "目的地页顶部主视觉", }, { id: "destinationRegions", label: "热门区域", hint: "对应目的地页“热门区域”快捷入口。", frontPosition: "目的地页「热门区域」", }, { id: "destinations", label: "省内目的地", hint: "维护目的地卡片名称、封面、热门状态、启停和展示顺序。", frontPosition: "目的地页「省内目的地」", }, ], }, { id: "detail", title: "商品维护", subtitle: "维护用户点击商品卡后的详情页内容和售卖状态。", frontPath: "前台:产品卡点击后详情页", modules: [ { id: "routeProducts", label: "商品库", hint: "编辑标题、价格、封面、标签、详情模块和上下架。", frontPosition: "详情页、搜索页、首页商品卡", }, ], }, { id: "campaign", title: "活动专题", subtitle: "维护轮播、主题卡和 CTA 指向的活动内容。", frontPath: "前台:轮播 / 主题卡 / CTA 跳转", modules: [ { id: "heroSlides", label: "轮播活动入口", hint: "首屏主视觉上的活动入口。", frontPosition: "首页轮播", }, { id: "themes", label: "主题活动入口", hint: "主题卡片进入对应活动专题。", frontPosition: "主题甄选横滑区", }, { id: "ctaBanners", label: "更多服务入口", hint: "维护更多服务里的活动和需求入口。", frontPosition: "首页「更多服务」", }, { id: "campaignProducts", label: "活动商品池", hint: "活动专题内展示的商品来自商品库。", frontPosition: "活动专题产品列表", }, ], }, { id: "demand", title: "需求线索", subtitle: "维护用户提交出行需求后的分配和跟进。", frontPath: "前台:提交出行需求 / 立即咨询", modules: [ { id: "leadFlow", label: "线索跟进", hint: "查看联系方式、目的地、来源商品并推进状态。", frontPosition: "需求表单提交后", }, ], }, ]; export function StructurePage({ fixedPage, onJump, onDirtyChange, notify, }: { fixedPage?: PageId; onJump: (tab: Tab) => void; onDirtyChange: DirtyChangeHandler; notify: Notify; }) { const [config, setConfig] = useState(null); const [products, setProducts] = useState([]); const [pageId, setPageId] = useState(fixedPage ?? "home"); const [moduleId, setModuleId] = useState("heroSlides"); const [selectedId, setSelectedId] = useState(""); const [draft, setDraft] = useState({}); const [message, setMessage] = useState(""); const [editorOpen, setEditorOpen] = useState(false); const [saving, setSaving] = useState(false); const [routeSectionCreateRequest, setRouteSectionCreateRequest] = useState(0); const activePage = pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0]; const activeModule = activePage.modules.find((module) => module.id === moduleId) ?? activePage.modules[0]; const isRouteSectionsModule = moduleId === "routeSections"; const editableItems = config && moduleEditable(moduleId) && !isRouteSectionsModule ? moduleItems(config, moduleId) : []; const isMapModule = moduleId === "map"; const isCampaignModule = moduleId === "campaigns"; const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions"; const canReorderSiteItems = moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule; const canCreateSiteItem = moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0); const showRouteSectionCreateButton = isRouteSectionsModule; const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID; const selectedItem = isCreatingSiteItem ? undefined : (editableItems.find((item) => item.id === selectedId) ?? editableItems[0]); const showEditorDrawer = editorOpen && moduleEditable(moduleId) && !isRouteSectionsModule && (isCreatingSiteItem || Boolean(selectedItem)); const gridClassName = [ "maintenance-grid", fixedPage ? "focused-page-grid" : "", ] .filter(Boolean) .join(" "); const setDraftFromItem = (item: EditableSiteItem) => { const imageValue = "image" in item ? item.image || "" : ""; const coverImageValue = "coverImage" in item ? item.coverImage || imageValue : moduleId === "hotelGroups" ? imageValue : undefined; const activeStatus = "status" in item && (item.status === "draft" || item.status === "published") ? item.status : moduleId === "hotelGroups" ? "isActive" in item && item.isActive === false ? "draft" : "published" : undefined; setDraft({ title: "title" in item ? item.title : undefined, kicker: "kicker" in item ? item.kicker || "" : undefined, name: "name" in item ? item.name : undefined, slug: "slug" in item ? item.slug : undefined, region: "region" in item ? item.region || "" : undefined, label: "label" in item ? item.label : undefined, keyword: "keyword" in item ? item.keyword || "" : undefined, spots: "spots" in item ? item.spots || "" : undefined, alt: "alt" in item ? item.alt : undefined, image: imageValue, description: "description" in item ? item.description || "" : undefined, coverImage: coverImageValue, priceAmount: "priceAmount" in item ? item.priceAmount : undefined, priceUnit: "priceUnit" in item ? item.priceUnit || (moduleId === "hotelGroups" ? "起/晚" : "起/人") : moduleId === "hotelGroups" ? "起/晚" : undefined, tags: "tags" in item && Array.isArray(item.tags) ? item.tags.slice(0, 3) : moduleId === "hotelGroups" ? [] : undefined, isHot: "isHot" in item ? item.isHot : undefined, isActive: "isActive" in item ? item.isActive : moduleId === "hotelGroups" ? activeStatus === "published" : undefined, sortOrder: itemSortOrder(item), status: activeStatus, startsAt: "startsAt" in item ? item.startsAt || "" : undefined, endsAt: "endsAt" in item ? item.endsAt || "" : undefined, }); }; const load = async (): Promise => { const [site, productResult] = await Promise.all([ getSiteConfig(), getProducts(), ]); setConfig(site); setProducts(productResult.items); const firstModule = activePage.modules[0]?.id ?? "heroSlides"; setModuleId((current) => activePage.modules.some((module) => module.id === current) ? current : firstModule, ); if (moduleEditable(firstModule)) { const first = moduleItems(site, firstModule)[0]; if (first && !selectedId) { setSelectedId(first.id); setDraftFromItem(first); } } return site; }; useEffect(() => { load().catch((err) => setMessage(err.message)); }, []); useEffect(() => { if (!fixedPage) return; setPageId(fixedPage); const next = pageSpecs.find((page) => page.id === fixedPage)?.modules[0]?.id ?? "heroSlides"; setModuleId(next); setSelectedId(""); setEditorOpen(false); }, [fixedPage]); useEffect(() => { if (!config || !moduleEditable(moduleId) || isRouteSectionsModule) { setSelectedId(""); setDraft({}); setEditorOpen(false); return; } const first = moduleItems(config, moduleId)[0]; if (!first) { setSelectedId(""); setDraft({}); setEditorOpen(false); return; } setSelectedId(first.id); setDraftFromItem(first); setEditorOpen(false); }, [moduleId, config]); const selectPage = (next: PageId) => { setPageId(next); const page = pageSpecs.find((item) => item.id === next); setModuleId(page?.modules[0]?.id ?? "heroSlides"); setSelectedId(""); setMessage(""); setEditorOpen(false); onDirtyChange(false); }; const startCreateSiteItem = () => { if (!moduleEditable(moduleId) || isRouteSectionsModule) return; setSelectedId(NEW_SITE_ITEM_ID); setDraft(createEmptySiteItemDraft(moduleId, editableItems.length)); setMessage(""); setEditorOpen(true); onDirtyChange(true); }; const selectItem = (item: EditableSiteItem) => { setSelectedId(item.id); setDraftFromItem(item); setMessage(""); setEditorOpen(true); onDirtyChange(false); }; const closeEditorDrawer = () => { setEditorOpen(false); setMessage(""); if (isCreatingSiteItem) { const first = editableItems[0]; if (first) { setSelectedId(first.id); setDraftFromItem(first); } else { setSelectedId(""); setDraft({}); } onDirtyChange(false); } }; const save = async () => { if ( saving || !config || !moduleEditable(moduleId) || isRouteSectionsModule || (!selectedId && !isCreatingSiteItem) ) return; const payload = compactSiteItemPayload(moduleId, draft); const primaryKey = siteItemPrimaryKey(moduleId); const primaryValue = String(payload[primaryKey] ?? "").trim(); if (!primaryValue) { const messageText = moduleId === "map" ? "请先上传贵州地图图片。" : moduleId === "campaigns" ? "请先填写特价优惠标题。" : "请先填写当前模块的主标题/名称。"; notify({ tone: "warning", title: "内容未保存", message: messageText }); return; } setSaving(true); setMessage(""); try { let saved: EditableSiteItem; const existingMapItem = moduleId === "map" ? moduleItems(config, "map")[0] : undefined; try { saved = moduleId === "map" && existingMapItem ? ((await updateSiteConfigItem( moduleId, existingMapItem.id, payload, )) as EditableSiteItem) : isCreatingSiteItem ? ((await createSiteConfigItem(moduleId, payload)) as EditableSiteItem) : ((await updateSiteConfigItem( moduleId, selectedId, payload, )) as EditableSiteItem); } catch (err) { if (moduleId !== "map" || !isCreatingSiteItem) throw err; const latestConfig = await getSiteConfig().catch(() => null); const latestMapItem = latestConfig ? moduleItems(latestConfig, "map")[0] : undefined; if (!latestMapItem) throw err; saved = (await updateSiteConfigItem( moduleId, latestMapItem.id, payload, )) as EditableSiteItem; } setSelectedId(saved.id); setDraftFromItem(saved); setMessage(""); onDirtyChange(false); notify({ tone: "success", title: isCreatingSiteItem ? "内容已新增" : "内容已保存", message: `${activeModule.label} 已同步到维护数据。`, }); await load(); } catch (err) { const messageText = err instanceof Error ? err.message : "保存失败"; setMessage(messageText); notify({ tone: "danger", title: "内容保存失败", message: messageText }); } finally { setSaving(false); } }; const removeSiteItem = async (item: EditableSiteItem) => { if (!moduleEditable(moduleId) || isRouteSectionsModule) return; const confirmed = window.confirm( `确认删除「${itemName(item)}」吗?删除后需要后端同步移除对应模块配置。`, ); if (!confirmed) return; try { await deleteSiteConfigItem(moduleId, item.id); setSelectedId(""); setDraft({}); setMessage(""); onDirtyChange(false); notify({ tone: "success", title: "内容已删除", message: `${activeModule.label} 已移除一项配置。`, }); await load(); } catch (err) { const messageText = err instanceof Error ? err.message : "删除失败"; setMessage(messageText); notify({ tone: "danger", title: "内容删除失败", message: messageText }); } }; const moveSiteItem = async (item: EditableSiteItem, direction: -1 | 1) => { if (!moduleEditable(moduleId) || isRouteSectionsModule) return; const currentIndex = editableItems.findIndex( (candidate) => candidate.id === item.id, ); const nextIndex = currentIndex + direction; if (currentIndex < 0 || nextIndex < 0 || nextIndex >= editableItems.length) return; const nextIds = editableItems.map((candidate) => candidate.id); [nextIds[currentIndex], nextIds[nextIndex]] = [ nextIds[nextIndex], nextIds[currentIndex], ]; try { await reorderSiteConfigItems(moduleId, nextIds); setMessage(""); onDirtyChange(false); notify({ tone: "success", title: "顺序已更新", message: `${activeModule.label} 已按新顺序保存。`, }); await load(); } catch (err) { const messageText = err instanceof Error ? err.message : "排序失败"; setMessage(messageText); notify({ tone: "danger", title: "排序保存失败", message: messageText }); } }; const publish = async () => { const result = await publishSite(); setMessage(""); notify({ tone: "success", title: "已生成发布版本", message: result.title }); }; const resetToGuizhou = async () => { const result = await resetGuizhouContent(); setMessage(""); notify({ tone: "success", title: "贵州内容已重置", message: "后台配置、商品库和素材引用已切换为贵州省内定制游。", }); setSelectedId(""); setDraft({}); onDirtyChange(false); await load(); }; if (!config) return ; return (

{fixedPage ? activePage.title : "小程序维护地图"}

{fixedPage ? activePage.subtitle : "按用户看到的前台页面组织后台,逐层展开到可维护内容。"}

{!fixedPage ? ( ) : (
当前维护页面

{activePage.title}

{activePage.frontPath}

{activePage.subtitle}
)}
{activePage.frontPath} {activePage.subtitle}

页面模块

{activePage.modules.map((module, index) => (
setModuleId(module.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setModuleId(module.id); } }} > {index + 1} {module.label} {module.frontPosition}
))}

{activeModule.label}

{activeModule.hint}

{canCreateSiteItem ? ( ) : showRouteSectionCreateButton ? ( ) : null}
{isRouteSectionsModule ? ( ) : moduleEditable(moduleId) ? (
{editableItems.map((item, index) => (
{canReorderSiteItems ? ( <> ) : null}
))} {!editableItems.length ? (

{isMapModule ? "暂无地图图片,点击上传地图。" : moduleId === "ctaBanners" ? "暂无更多服务卡片,点击新增创建第一项。" : "暂无配置数据,点击新增创建第一项。"}

) : null}
) : ( )}
{showEditorDrawer && moduleEditable(moduleId) ? (
) : null}
); } function CampaignTagEditor({ tags, onChange, }: { tags: string[]; onChange: (tags: string[]) => void; }) { const normalizedTags = tags.slice(0, 3); const updateTag = (index: number, value: string) => { onChange( normalizedTags.map((tag, tagIndex) => tagIndex === index ? value : tag, ), ); }; const removeTag = (index: number) => { onChange(normalizedTags.filter((_, tagIndex) => tagIndex !== index)); }; const addTag = () => { if (normalizedTags.length >= 3) return; onChange([...normalizedTags, ""]); }; return (
{normalizedTags.map((tag, index) => ( ))}

最多维护 3 个标签,保存时会自动去掉空标签。

); } function ReferencePanel({ moduleId, products, onJump, }: { moduleId: ModuleId; products: Product[]; onJump: (tab: Tab) => void; }) { if (moduleId === "leadFlow") { return (

这个模块由客户需求列表维护,重点是状态流转、服务管家跟进和来源追踪。

); } return (

这个模块引用商品库。编辑商品后,首页商品卡、搜索结果和详情页会同步使用同一份数据。

{products.slice(0, 4).map((product) => ( {product.title} ))}
); } function SiteItemEditor({ moduleId, moduleLabel, draft, isCreating, saving, onDraft, onSave, onClose, }: { moduleId: SiteModule; moduleLabel: string; draft: SiteItemPatch; isCreating: boolean; saving: boolean; onDraft: (draft: SiteItemPatch) => void; onSave: () => void; onClose: () => void; }) { const primaryKey = siteItemPrimaryKey(moduleId); const primaryLabel = (() => { if (moduleId === "heroSlides") return "轮播标题"; if (moduleId === "destinations") return "目的地名称"; if (moduleId === "destinationHero") return "主视觉标题"; if (moduleId === "destinationRegions") return "区域名称"; if (moduleId === "map") return "地图图片"; if (moduleId === "themes") return "主题名称"; if (moduleId === "campaigns") return "活动标题"; if (moduleId === "hotelGroups") return "酒店标题"; if (moduleId === "vehicleOptions") return "用车标题"; if (moduleId === "ctaBanners") return "服务标题"; return "入口文案"; })(); const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`; const isImageOnlyModule = moduleId === "map"; const isCampaignModule = moduleId === "campaigns"; const isHotelModule = moduleId === "hotelGroups"; const isDestinationHeroModule = moduleId === "destinationHero"; const isDestinationRegionModule = moduleId === "destinationRegions"; const isTextOnlyModule = isDestinationRegionModule; const usesOfferConfig = isCampaignModule || isHotelModule; const isMoreServicesModule = moduleId === "ctaBanners"; const isCardContentModule = moduleId === "vehicleOptions"; const editorHint = isImageOnlyModule ? isCreating ? `上传后会作为「${moduleLabel}」唯一展示图片` : `保存后会替换「${moduleLabel}」前台展示图片` : usesOfferConfig ? isCampaignModule ? isCreating ? `创建后会进入「${moduleLabel}」的活动列表` : `保存后会影响「${moduleLabel}」活动入口` : isCreating ? `创建后会进入「${moduleLabel}」的数据列表` : `保存后会影响「${moduleLabel}」首页卡片` : isMoreServicesModule ? isCreating ? "创建后会进入首页「更多服务」卡片列表" : "保存后会影响首页「更多服务」卡片" : isCreating ? `创建后会进入「${moduleLabel}」的数据列表` : `保存后会影响「${moduleLabel}」前台模块`; return (

{editorTitle}

{editorHint}
{!isImageOnlyModule ? ( {usesOfferConfig ? ( <>