Files
WonderQ-Admin-UI/src/pages/structure/StructurePage.tsx

905 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}