feat: enhance admin UI with responsive styles and new components
- Updated styles in src/styles.css for better responsiveness and layout adjustments. - Added new CSS classes for edit drawer and item rail components. - Introduced animations for drawer transitions. - Created new types in src/types/admin.ts for better type safety in admin features. - Modified vite.config.ts to change server port and enable strict port settings for development.
This commit is contained in:
851
src/pages/structure/StructurePage.tsx
Normal file
851
src/pages/structure/StructurePage.tsx
Normal file
@@ -0,0 +1,851 @@
|
||||
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: "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 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 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);
|
||||
if (isCreatingSiteItem) {
|
||||
const first = editableItems[0];
|
||||
if (first) {
|
||||
setSelectedId(first.id);
|
||||
setDraftFromItem(first);
|
||||
} else {
|
||||
setSelectedId("");
|
||||
setDraft({});
|
||||
}
|
||||
onDirtyChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (
|
||||
!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 = "请先填写当前模块的主标题/名称。";
|
||||
notify({ tone: "warning", title: "内容未保存", message: messageText });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = isCreatingSiteItem
|
||||
? await createSiteConfigItem(moduleId, payload)
|
||||
: await updateSiteConfigItem(moduleId, selectedId, payload);
|
||||
setSelectedId(saved.id);
|
||||
setDraftFromItem(saved as EditableSiteItem);
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{moduleEditable(moduleId) ? (
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={startCreateSiteItem}
|
||||
>
|
||||
<Plus size={16} />
|
||||
新增
|
||||
</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)}
|
||||
{typeof itemSortOrder(item) === "number"
|
||||
? ` · 排序 ${itemSortOrder(item)}`
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
</Button>
|
||||
<div
|
||||
className="mapped-list-actions"
|
||||
aria-label={`${itemName(item)} 操作`}
|
||||
>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeSiteItem(item)}
|
||||
aria-label={`删除 ${itemName(item)}`}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!editableItems.length ? (
|
||||
<p className="mapped-empty">
|
||||
暂无配置数据,点击新增创建第一项。
|
||||
</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}
|
||||
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,
|
||||
onDraft,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
moduleId: SiteModule;
|
||||
moduleLabel: string;
|
||||
draft: SiteItemPatch;
|
||||
isCreating: boolean;
|
||||
onDraft: (draft: SiteItemPatch) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const primaryKey = siteItemPrimaryKey(moduleId);
|
||||
const primaryLabel =
|
||||
moduleId === "heroSlides"
|
||||
? "轮播标题"
|
||||
: moduleId === "destinations"
|
||||
? "目的地名称"
|
||||
: moduleId === "themes"
|
||||
? "主题名称"
|
||||
: "入口文案";
|
||||
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
||||
|
||||
return (
|
||||
<div className="mapped-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3 id="site-item-editor-title">{editorTitle}</h3>
|
||||
<small>
|
||||
{isCreating
|
||||
? `创建后会进入「${moduleLabel}」的数据列表`
|
||||
: `保存后会影响「${moduleLabel}」前台模块`}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭编辑抽屉"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="admin-disclosure-stack">
|
||||
<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}
|
||||
{moduleId === "destinations" ? (
|
||||
<div className="form-grid compact-grid">
|
||||
<label>
|
||||
目的地标识
|
||||
<Input
|
||||
value={draft.slug ?? ""}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, slug: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
所属区域
|
||||
<Input
|
||||
value={draft.region ?? ""}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, region: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminDisclosure>
|
||||
<AdminDisclosure title="资源图片">
|
||||
<SingleImageUploader
|
||||
value={draft.image}
|
||||
onChange={(image) => onDraft({ ...draft, image })}
|
||||
group={moduleId}
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
<AdminDisclosure title="排序与状态">
|
||||
<label>
|
||||
排序值
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.sortOrder ?? 0}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{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}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="primary-action compact" onClick={onSave}>
|
||||
<Save size={17} />
|
||||
{isCreating ? "创建" : "保存"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user