905 lines
27 KiB
TypeScript
905 lines
27 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 { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
compactSiteItemPayload,
|
||
createEmptySiteItemDraft,
|
||
itemImage,
|
||
itemMeta,
|
||
itemName,
|
||
itemSortOrder,
|
||
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 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: "routeProducts",
|
||
label: "精选线路",
|
||
hint: "从商品库选择上架线路,决定首页线路池。",
|
||
frontPosition: "首页精选线路",
|
||
},
|
||
{
|
||
id: "ctaBanners",
|
||
label: "底部运营入口",
|
||
hint: "控制权益卡、服务管家入口、目的地搜索、提交需求入口。",
|
||
frontPosition: "首页页尾 CTA",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "destination",
|
||
title: "目的地页",
|
||
subtitle: "维护目的地、别名和与商品的关联,影响搜索和目的地 tab。",
|
||
frontPath: "前台:bottom tab「目的地」/ 搜索弹层",
|
||
modules: [
|
||
{
|
||
id: "destinations",
|
||
label: "目的地字典",
|
||
hint: "维护名称、封面、热门状态和启停。",
|
||
frontPosition: "目的地首页和搜索列表",
|
||
},
|
||
{
|
||
id: "routeProducts",
|
||
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: "营销 CTA",
|
||
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 activePage =
|
||
pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0];
|
||
const activeModule =
|
||
activePage.modules.find((module) => module.id === moduleId) ??
|
||
activePage.modules[0];
|
||
const editableItems =
|
||
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
|
||
const isMapModule = moduleId === "map";
|
||
const canCreateSiteItem =
|
||
moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0);
|
||
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
||
const selectedItem = isCreatingSiteItem
|
||
? undefined
|
||
: (editableItems.find((item) => item.id === selectedId) ??
|
||
editableItems[0]);
|
||
const showEditorDrawer =
|
||
editorOpen &&
|
||
moduleEditable(moduleId) &&
|
||
(isCreatingSiteItem || Boolean(selectedItem));
|
||
const gridClassName = [
|
||
"maintenance-grid",
|
||
fixedPage ? "focused-page-grid" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
const setDraftFromItem = (item: EditableSiteItem) => {
|
||
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,
|
||
alt: "alt" in item ? item.alt : undefined,
|
||
image: "image" in item ? item.image || "" : "",
|
||
isHot: "isHot" in item ? item.isHot : undefined,
|
||
isActive: item.isActive,
|
||
sortOrder: itemSortOrder(item),
|
||
});
|
||
};
|
||
|
||
const load = async () => {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
|
||
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)) {
|
||
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)) 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) ||
|
||
(!selectedId && !isCreatingSiteItem)
|
||
)
|
||
return;
|
||
const payload = compactSiteItemPayload(moduleId, draft);
|
||
const primaryKey = siteItemPrimaryKey(moduleId);
|
||
const primaryValue = String(payload[primaryKey] ?? "").trim();
|
||
|
||
if (!primaryValue) {
|
||
const messageText =
|
||
moduleId === "map"
|
||
? "请先上传贵州地图图片。"
|
||
: "请先填写当前模块的主标题/名称。";
|
||
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)) 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)) 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) => (
|
||
<Button
|
||
variant="ghost"
|
||
className={activeModule.id === module.id ? "active" : ""}
|
||
key={module.id}
|
||
onClick={() => setModuleId(module.id)}
|
||
>
|
||
<em>{index + 1}</em>
|
||
<span>
|
||
<b>{module.label}</b>
|
||
<small>{module.frontPosition}</small>
|
||
</span>
|
||
</Button>
|
||
))}
|
||
</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>
|
||
) : null}
|
||
</header>
|
||
{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)}
|
||
{!isMapModule && typeof itemSortOrder(item) === "number"
|
||
? ` · 排序 ${itemSortOrder(item)}`
|
||
: ""}
|
||
</small>
|
||
</span>
|
||
</Button>
|
||
<div
|
||
className="mapped-list-actions"
|
||
aria-label={`${itemName(item)} 操作`}
|
||
>
|
||
{!isMapModule ? (
|
||
<>
|
||
<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
|
||
? "暂无地图图片,点击上传地图。"
|
||
: "暂无配置数据,点击新增创建第一项。"}
|
||
</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 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 =
|
||
moduleId === "heroSlides"
|
||
? "轮播标题"
|
||
: moduleId === "destinations"
|
||
? "目的地名称"
|
||
: moduleId === "map"
|
||
? "地图图片"
|
||
: moduleId === "themes"
|
||
? "主题名称"
|
||
: "入口文案";
|
||
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
||
const isImageOnlyModule = moduleId === "map";
|
||
const editorHint = isImageOnlyModule
|
||
? isCreating
|
||
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
||
: `保存后会替换「${moduleLabel}」前台展示图片`
|
||
: 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="展示内容">
|
||
<label>
|
||
{primaryLabel}
|
||
<Input
|
||
value={String(draft[primaryKey] ?? "")}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, [primaryKey]: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
{moduleId === "heroSlides" ? (
|
||
<label>
|
||
副标题
|
||
<Input
|
||
value={draft.kicker ?? ""}
|
||
onChange={(event) =>
|
||
onDraft({ ...draft, kicker: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</AdminDisclosure>
|
||
) : null}
|
||
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : "资源图片"}>
|
||
<SingleImageUploader
|
||
value={draft.image}
|
||
onChange={(image) => onDraft({ ...draft, image })}
|
||
group={moduleId}
|
||
/>
|
||
</AdminDisclosure>
|
||
<AdminDisclosure title={isImageOnlyModule ? "显示状态" : "排序与状态"}>
|
||
{!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>
|
||
);
|
||
}
|