Add new API type definitions for DestinationHero and DestinationRegion Extend SiteConfig and SiteModule to include the new site modules Update destination page sidebar label and structure page configuration Add admin editor UI, payload handling, and empty draft generators for the new modules Adjust item metadata and form fields to support the new site item types
1257 lines
41 KiB
TypeScript
1257 lines
41 KiB
TypeScript
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<SiteConfig | null>(null);
|
||
const [products, setProducts] = useState<Product[]>([]);
|
||
const [pageId, setPageId] = useState<PageId>(fixedPage ?? "home");
|
||
const [moduleId, setModuleId] = useState<ModuleId>("heroSlides");
|
||
const [selectedId, setSelectedId] = useState("");
|
||
const [draft, setDraft] = useState<SiteItemPatch>({});
|
||
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<SiteConfig> => {
|
||
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 <EmptyState text={message || "正在读取小程序结构"} />;
|
||
|
||
return (
|
||
<section className="work-area structure-workbench">
|
||
<header className="section-head">
|
||
<div>
|
||
<h2>{fixedPage ? activePage.title : "小程序维护地图"}</h2>
|
||
<p>
|
||
{fixedPage
|
||
? activePage.subtitle
|
||
: "按用户看到的前台页面组织后台,逐层展开到可维护内容。"}
|
||
</p>
|
||
</div>
|
||
<div className="action-row">
|
||
<Button variant="outline" onClick={load}>
|
||
<RefreshCcw size={17} />
|
||
刷新
|
||
</Button>
|
||
<Button className="primary-action compact" onClick={publish}>
|
||
<Send size={17} />
|
||
发布快照
|
||
</Button>
|
||
<Button variant="outline" onClick={resetToGuizhou}>
|
||
<RefreshCcw size={17} />
|
||
重置贵州内容
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
<div className={gridClassName}>
|
||
{!fixedPage ? (
|
||
<aside className="page-rail">
|
||
<h3>前台页面</h3>
|
||
{pageSpecs.map((page) => (
|
||
<Button
|
||
variant="ghost"
|
||
className={activePage.id === page.id ? "active" : ""}
|
||
key={page.id}
|
||
onClick={() => selectPage(page.id)}
|
||
>
|
||
<span>
|
||
<b>{page.title}</b>
|
||
<small>{page.frontPath}</small>
|
||
</span>
|
||
<ChevronRight size={16} />
|
||
</Button>
|
||
))}
|
||
</aside>
|
||
) : (
|
||
<section className="page-focus-card">
|
||
<span className="focus-kicker">当前维护页面</span>
|
||
<h3>{activePage.title}</h3>
|
||
<p>{activePage.frontPath}</p>
|
||
<small>{activePage.subtitle}</small>
|
||
</section>
|
||
)}
|
||
<section className="module-rail">
|
||
<div className="front-context">
|
||
<Eye size={18} />
|
||
<span>
|
||
<b>{activePage.frontPath}</b>
|
||
<small>{activePage.subtitle}</small>
|
||
</span>
|
||
</div>
|
||
<h3>页面模块</h3>
|
||
{activePage.modules.map((module, index) => (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-pressed={activeModule.id === module.id}
|
||
className={`module-rail-item${activeModule.id === module.id ? " active" : ""}`}
|
||
key={module.id}
|
||
onClick={() => setModuleId(module.id)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
setModuleId(module.id);
|
||
}
|
||
}}
|
||
>
|
||
<em>{index + 1}</em>
|
||
<span>
|
||
<b>{module.label}</b>
|
||
<small>{module.frontPosition}</small>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</section>
|
||
<section className="item-rail">
|
||
<header className="module-data-head">
|
||
<span>
|
||
<h3>{activeModule.label}</h3>
|
||
<p>{activeModule.hint}</p>
|
||
</span>
|
||
{canCreateSiteItem ? (
|
||
<Button
|
||
className="primary-action compact"
|
||
onClick={startCreateSiteItem}
|
||
>
|
||
<Plus size={16} />
|
||
{isMapModule ? "上传地图" : "新增"}
|
||
</Button>
|
||
) : showRouteSectionCreateButton ? (
|
||
<Button
|
||
className="primary-action compact"
|
||
onClick={() => setRouteSectionCreateRequest((current) => current + 1)}
|
||
>
|
||
<Plus size={16} />
|
||
新增
|
||
</Button>
|
||
) : null}
|
||
</header>
|
||
{isRouteSectionsModule ? (
|
||
<RouteSectionsPanel
|
||
routeSections={config.routeSections}
|
||
products={products}
|
||
moduleLabel={activeModule.label}
|
||
onDirtyChange={onDirtyChange}
|
||
notify={notify}
|
||
onReload={load}
|
||
createRequestKey={routeSectionCreateRequest}
|
||
/>
|
||
) : moduleEditable(moduleId) ? (
|
||
<div className="mapped-list">
|
||
{editableItems.map((item, index) => (
|
||
<div className="mapped-list-row" key={item.id}>
|
||
<Button
|
||
variant="ghost"
|
||
className={`mapped-list-main ${selectedItem?.id === item.id ? "selected" : ""}`}
|
||
onClick={() => selectItem(item)}
|
||
>
|
||
{itemImage(item) ? (
|
||
<img src={itemImage(item)} alt="" />
|
||
) : (
|
||
<span className="image-placeholder" />
|
||
)}
|
||
<span>
|
||
<b>{itemName(item)}</b>
|
||
<small>
|
||
{itemMeta(item)}
|
||
{canReorderSiteItems && typeof itemSortOrder(item) === "number"
|
||
? ` · 排序 ${itemSortOrder(item)}`
|
||
: ""}
|
||
</small>
|
||
{itemTags(item).length ? (
|
||
<span className="mapped-list-tags">
|
||
{itemTags(item).map((tag) => (
|
||
<em key={tag}>{tag}</em>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</Button>
|
||
<div
|
||
className="mapped-list-actions"
|
||
aria-label={`${itemName(item)} 操作`}
|
||
>
|
||
{canReorderSiteItems ? (
|
||
<>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={index === 0}
|
||
onClick={() => moveSiteItem(item, -1)}
|
||
aria-label={`上移 ${itemName(item)}`}
|
||
>
|
||
<ArrowUp size={15} />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={index === editableItems.length - 1}
|
||
onClick={() => moveSiteItem(item, 1)}
|
||
aria-label={`下移 ${itemName(item)}`}
|
||
>
|
||
<ArrowDown size={15} />
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeSiteItem(item)}
|
||
aria-label={`删除 ${itemName(item)}`}
|
||
>
|
||
<Trash2 size={15} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!editableItems.length ? (
|
||
<p className="mapped-empty">
|
||
{isMapModule
|
||
? "暂无地图图片,点击上传地图。"
|
||
: moduleId === "ctaBanners"
|
||
? "暂无更多服务卡片,点击新增创建第一项。"
|
||
: "暂无配置数据,点击新增创建第一项。"}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<ReferencePanel
|
||
moduleId={moduleId}
|
||
products={products}
|
||
onJump={onJump}
|
||
/>
|
||
)}
|
||
</section>
|
||
</div>
|
||
{showEditorDrawer && moduleEditable(moduleId) ? (
|
||
<div className="edit-drawer-layer">
|
||
<button
|
||
className="edit-drawer-backdrop"
|
||
type="button"
|
||
aria-label="关闭编辑抽屉"
|
||
onClick={closeEditorDrawer}
|
||
/>
|
||
<section
|
||
className="edit-rail edit-drawer"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="site-item-editor-title"
|
||
>
|
||
<SiteItemEditor
|
||
moduleId={moduleId}
|
||
moduleLabel={activeModule.label}
|
||
draft={draft}
|
||
isCreating={isCreatingSiteItem}
|
||
saving={saving}
|
||
onDraft={(nextDraft) => {
|
||
setDraft(nextDraft);
|
||
onDirtyChange(true);
|
||
}}
|
||
onSave={save}
|
||
onClose={closeEditorDrawer}
|
||
/>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="tag-editor compact-tag-editor">
|
||
{normalizedTags.map((tag, index) => (
|
||
<label className="tag-input-row" key={`${index}-${tag}`}>
|
||
标签 {index + 1}
|
||
<span>
|
||
<Input
|
||
value={tag}
|
||
onChange={(event) => updateTag(index, event.target.value)}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="icon-action danger-icon"
|
||
onClick={() => removeTag(index)}
|
||
aria-label={`删除标签 ${index + 1}`}
|
||
>
|
||
<X size={16} />
|
||
</Button>
|
||
</span>
|
||
</label>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="inline-action"
|
||
onClick={addTag}
|
||
disabled={normalizedTags.length >= 3}
|
||
>
|
||
<Plus size={16} />
|
||
添加标签
|
||
</Button>
|
||
<p className="field-help">最多维护 3 个标签,保存时会自动去掉空标签。</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReferencePanel({
|
||
moduleId,
|
||
products,
|
||
onJump,
|
||
}: {
|
||
moduleId: ModuleId;
|
||
products: Product[];
|
||
onJump: (tab: Tab) => void;
|
||
}) {
|
||
if (moduleId === "leadFlow") {
|
||
return (
|
||
<div className="reference-panel">
|
||
<p>
|
||
这个模块由客户需求列表维护,重点是状态流转、服务管家跟进和来源追踪。
|
||
</p>
|
||
<Button
|
||
className="primary-action compact"
|
||
onClick={() => onJump("leads")}
|
||
>
|
||
进入需求线索
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="reference-panel">
|
||
<p>
|
||
这个模块引用商品库。编辑商品后,首页商品卡、搜索结果和详情页会同步使用同一份数据。
|
||
</p>
|
||
<div className="mini-product-stack">
|
||
{products.slice(0, 4).map((product) => (
|
||
<span key={product.id}>
|
||
<img src={product.coverImage || ""} alt="" />
|
||
<b>{product.title}</b>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<Button
|
||
className="primary-action compact"
|
||
onClick={() => onJump("products")}
|
||
>
|
||
进入商品维护
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mapped-editor">
|
||
<header className="editor-head">
|
||
<span>
|
||
<h3 id="site-item-editor-title">{editorTitle}</h3>
|
||
<small>{editorHint}</small>
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={onClose}
|
||
aria-label="关闭编辑抽屉"
|
||
>
|
||
<X size={18} />
|
||
</Button>
|
||
</header>
|
||
<div className="admin-disclosure-stack">
|
||
{!isImageOnlyModule ? (
|
||
<AdminDisclosure title="展示内容">
|
||
{usesOfferConfig ? (
|
||
<>
|
||
<label>
|
||
{primaryLabel}
|
||
<Input
|
||
value={draft.title ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, title: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
{isHotelModule ? "酒店描述" : "活动描述"}
|
||
<Textarea
|
||
value={draft.description ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, description: event.target.value })
|
||
}
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
</>
|
||
) : (
|
||
<>
|
||
<label>
|
||
{primaryLabel}
|
||
<Input
|
||
value={String(draft[primaryKey] ?? "")}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, [primaryKey]: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
{moduleId === "heroSlides" || isDestinationHeroModule ? (
|
||
<label>
|
||
{isDestinationHeroModule ? "上方小标题" : "副标题"}
|
||
<Input
|
||
value={draft.kicker ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, kicker: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
{isDestinationRegionModule ? (
|
||
<>
|
||
<label>
|
||
搜索关键词
|
||
<Input
|
||
value={draft.keyword ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, keyword: event.target.value })
|
||
}
|
||
placeholder="为空时使用区域名称"
|
||
/>
|
||
</label>
|
||
<label>
|
||
景点摘要
|
||
<Textarea
|
||
value={draft.spots ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, spots: event.target.value })
|
||
}
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
</>
|
||
) : null}
|
||
{isCardContentModule ? (
|
||
<label>
|
||
卡片描述
|
||
<Textarea
|
||
value={draft.description ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, description: event.target.value })
|
||
}
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</AdminDisclosure>
|
||
) : null}
|
||
{isMoreServicesModule ? (
|
||
<AdminDisclosure title="跳转配置">
|
||
<div className="form-grid compact-grid">
|
||
<label>
|
||
跳转类型
|
||
<NativeSelect
|
||
value={draft.targetType ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, targetType: event.target.value })
|
||
}
|
||
>
|
||
{moreServicesTargetOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
</label>
|
||
<label>
|
||
目标参数
|
||
<Input
|
||
value={draft.targetValue ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, targetValue: event.target.value })
|
||
}
|
||
placeholder="可选,如需求页目的地"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</AdminDisclosure>
|
||
) : null}
|
||
{usesOfferConfig ? (
|
||
<AdminDisclosure title="价格信息">
|
||
<div className="form-grid compact-grid">
|
||
<label>
|
||
价格
|
||
<Input
|
||
type="number"
|
||
value={draft.priceAmount ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({
|
||
...draft,
|
||
priceAmount: event.target.value
|
||
? Number(event.target.value)
|
||
: null,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
价格单位
|
||
<Input
|
||
value={draft.priceUnit ?? (isHotelModule ? "起/晚" : "起/人")}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, priceUnit: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</AdminDisclosure>
|
||
) : null}
|
||
{usesOfferConfig ? (
|
||
<AdminDisclosure title={isHotelModule ? "酒店标签" : "活动标签"}>
|
||
<CampaignTagEditor
|
||
tags={draft.tags ?? []}
|
||
onChange={(tags) => onDraft({ ...draft, tags })}
|
||
/>
|
||
</AdminDisclosure>
|
||
) : null}
|
||
{!isTextOnlyModule ? (
|
||
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : usesOfferConfig ? isHotelModule ? "酒店封面图" : "活动封面图" : isDestinationHeroModule ? "主视觉图片" : isMoreServicesModule ? "服务卡片背景图" : "资源图片"}>
|
||
<SingleImageUploader
|
||
value={usesOfferConfig ? draft.coverImage ?? draft.image : draft.image}
|
||
onChange={(image) =>
|
||
onDraft(
|
||
isCampaignModule
|
||
? { ...draft, coverImage: image }
|
||
: isHotelModule
|
||
? { ...draft, coverImage: image, image }
|
||
: { ...draft, image },
|
||
)
|
||
}
|
||
group={moduleId}
|
||
/>
|
||
</AdminDisclosure>
|
||
) : null}
|
||
<AdminDisclosure title={isImageOnlyModule || usesOfferConfig ? "显示状态" : "排序与状态"}>
|
||
{usesOfferConfig ? (
|
||
<label className="switch-line">
|
||
<Switch
|
||
checked={draft.status === "published" || (isHotelModule && !draft.status && Boolean(draft.isActive))}
|
||
onCheckedChange={(checked) =>
|
||
onDraft(
|
||
isHotelModule
|
||
? { ...draft, status: checked ? "published" : "draft", isActive: checked }
|
||
: { ...draft, status: checked ? "published" : "draft" },
|
||
)
|
||
}
|
||
/>
|
||
前台启用
|
||
</label>
|
||
) : (
|
||
<>
|
||
{!isImageOnlyModule ? (
|
||
<label>
|
||
排序值
|
||
<Input
|
||
type="number"
|
||
value={draft.sortOrder ?? 0}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
{moduleId === "destinations" ? (
|
||
<label className="switch-line">
|
||
<Switch
|
||
checked={Boolean(draft.isHot)}
|
||
onCheckedChange={(checked) =>
|
||
onDraft({ ...draft, isHot: checked })
|
||
}
|
||
/>
|
||
设为热门
|
||
</label>
|
||
) : null}
|
||
<label className="switch-line">
|
||
<Switch
|
||
checked={Boolean(draft.isActive)}
|
||
onCheckedChange={(checked) =>
|
||
onDraft({ ...draft, isActive: checked })
|
||
}
|
||
/>
|
||
前台启用
|
||
</label>
|
||
</>
|
||
)}
|
||
</AdminDisclosure>
|
||
</div>
|
||
<footer className="drawer-editor-footer">
|
||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
className="primary-action compact"
|
||
onClick={onSave}
|
||
disabled={saving}
|
||
>
|
||
<Save size={17} />
|
||
{saving ? "保存中" : isCreating ? "创建" : "保存"}
|
||
</Button>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|