feat(admin): add destination hero and region admin modules

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
This commit is contained in:
duanshuwen
2026-07-07 21:24:38 +08:00
parent a675092989
commit 3b3d8d0aa1
5 changed files with 130 additions and 30 deletions

View File

@@ -23,6 +23,28 @@ export type Destination = {
_count?: { products: number };
};
export type DestinationHero = {
id: string;
title: string;
kicker: string | null;
image: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
export type DestinationRegion = {
id: string;
label: string;
keyword?: string | null;
spots?: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
export type Product = {
id: string;
sourceId?: number | null;
@@ -144,6 +166,8 @@ export type VehicleOption = {
export type SiteConfig = {
heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
destinations: Destination[];
destinationHero: DestinationHero[];
destinationRegions: DestinationRegion[];
map: Array<{ id: string; image: string | null; isActive: boolean; createdAt?: string; updatedAt?: string }>;
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
campaigns: Campaign[];
@@ -153,18 +177,20 @@ export type SiteConfig = {
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
};
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
export type SiteModule = "heroSlides" | "destinations" | "destinationHero" | "destinationRegions" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
export type SiteConfigItem = SiteConfig[SiteModule][number];
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
export type SiteItemPatch = {
title?: string;
subtitle?: string | null;
kicker?: string;
kicker?: string | null;
name?: string;
slug?: string;
region?: string | null;
label?: string;
keyword?: string | null;
spots?: string | null;
alt?: string;
image?: string | null;
description?: string | null;

View File

@@ -21,7 +21,7 @@ const sidebarGroups: { id: string; label: string; items: { id: Tab; label: strin
label: "前台页面",
items: [
{ id: "home", label: "小程序首页", icon: Home, description: "轮播、主题、CTA" },
{ id: "destinations", label: "目的地页", icon: MapPin, description: "字典、搜索、商品" },
{ id: "destinations", label: "目的地页", icon: MapPin, description: "主视觉、热门、省内" },
{ id: "products", label: "商品维护", icon: Package, description: "列表、卡片、详情" },
],
},

View File

@@ -129,6 +129,8 @@ export function compactProductPayload(draft: ProductInput): ProductInput {
export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
if (moduleId === "heroSlides") return "title";
if (moduleId === "destinations") return "name";
if (moduleId === "destinationHero") return "title";
if (moduleId === "destinationRegions") return "label";
if (moduleId === "map") return "image";
if (moduleId === "themes") return "label";
if (moduleId === "campaigns") return "title";
@@ -157,6 +159,12 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
if (moduleId === "destinations") {
return { name: "", image: "", isActive: false, sortOrder };
}
if (moduleId === "destinationHero") {
return { title: "", kicker: "", image: "", isActive: true, sortOrder };
}
if (moduleId === "destinationRegions") {
return { label: "", keyword: "", spots: "", isActive: true, sortOrder };
}
if (moduleId === "map") {
return { image: "", isActive: false };
}
@@ -218,6 +226,26 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
payload.region = draft.region?.trim() || null;
payload.isHot = Boolean(draft.isHot);
}
if (moduleId === "destinationHero") {
payload.title = draft.title?.trim();
payload.kicker = draft.kicker?.trim() || null;
payload.image = draft.image?.trim() || null;
payload.productIds = undefined;
payload.subtitle = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "destinationRegions") {
payload.label = draft.label?.trim();
payload.keyword = draft.keyword?.trim() || null;
payload.spots = draft.spots?.trim() || null;
payload.image = undefined;
payload.productIds = undefined;
payload.kicker = undefined;
payload.subtitle = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "map") {
payload.sortOrder = undefined;
}
@@ -311,6 +339,11 @@ export function itemName(item: EditableSiteItem) {
export function itemMeta(item: EditableSiteItem): string {
if ("kicker" in item) return item.kicker || "轮播";
if ("spots" in item || "keyword" in item) {
const spots = "spots" in item ? item.spots : "";
const keyword = "keyword" in item ? item.keyword : "";
return [spots, keyword].filter(Boolean).join(" / ") || "热门区域";
}
if ("aliases" in item) return item.region || item.aliases?.map((alias) => alias.alias).join(" / ") || "目的地";
if ("priceAmount" in item) {
const priceUnit = "priceUnit" in item && typeof item.priceUnit === "string" ? item.priceUnit : "";
@@ -349,5 +382,5 @@ export function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableS
}
export function moduleEditable(moduleId: ModuleId): moduleId is SiteModule {
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "hotelGroups" || moduleId === "vehicleOptions" || moduleId === "ctaBanners";
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "destinationHero" || moduleId === "destinationRegions" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "hotelGroups" || moduleId === "vehicleOptions" || moduleId === "ctaBanners";
}

View File

@@ -139,20 +139,26 @@ const pageSpecs: {
{
id: "destination",
title: "目的地页",
subtitle: "维护目的地、别名和与商品的关联,影响搜索和目的地 tab。",
subtitle: "按前台目的地页模块核对数据来源和维护入口。",
frontPath: "前台bottom tab「目的地」/ 搜索弹层",
modules: [
{
id: "destinations",
label: "目的地字典",
hint: "维护名称、封面、热门状态和启停。",
frontPosition: "目的地首页和搜索列表",
id: "destinationHero",
label: "顶部主视觉",
hint: "对应目的地页顶部大图和“贵州小包团目的地”文案。",
frontPosition: "目的地页顶部主视觉",
},
{
id: "routeProducts",
label: "目的地商品",
hint: "商品绑定目的地后会进入对应结果页。",
frontPosition: "搜索结果页产品列表",
id: "destinationRegions",
label: "热门区域",
hint: "对应目的地页“热门区域”快捷入口。",
frontPosition: "目的地页「热门区域」",
},
{
id: "destinations",
label: "省内目的地",
hint: "维护目的地卡片名称、封面、热门状态、启停和展示顺序。",
frontPosition: "目的地页「省内目的地」",
},
],
},
@@ -299,6 +305,8 @@ export function StructurePage({
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,
@@ -948,6 +956,8 @@ function SiteItemEditor({
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 "活动标题";
@@ -960,6 +970,9 @@ function SiteItemEditor({
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";
@@ -1035,9 +1048,9 @@ function SiteItemEditor({
}
/>
</label>
{moduleId === "heroSlides" ? (
{moduleId === "heroSlides" || isDestinationHeroModule ? (
<label>
{isDestinationHeroModule ? "上方小标题" : "副标题"}
<Input
value={draft.kicker ?? ""}
onChange={(event) =>
@@ -1046,6 +1059,30 @@ function SiteItemEditor({
/>
</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>
@@ -1131,21 +1168,23 @@ function SiteItemEditor({
/>
</AdminDisclosure>
) : null}
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : usesOfferConfig ? isHotelModule ? "酒店封面图" : "活动封面图" : 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>
{!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">

View File

@@ -17,6 +17,8 @@ export type DirtyChangeHandler = (dirty: boolean) => void;
export type EditableSiteItem =
| SiteConfig["heroSlides"][number]
| SiteConfig["destinations"][number]
| SiteConfig["destinationHero"][number]
| SiteConfig["destinationRegions"][number]
| SiteConfig["map"][number]
| SiteConfig["themes"][number]
| SiteConfig["campaigns"][number]