feat(admin): add hotel groups and vehicle options site modules

Add support for two new standalone site content modules in the admin dashboard:
hotel groups and vehicle options. This commit includes:
- Added TypeScript types for HotelGroup and VehicleOption, extended SiteConfig and SiteModule enum
- Updated admin utilities (primary key lookup, empty drafts, payload compaction) for the new modules
- Added management UI panels and editors in the structure page for configuring the new modules
- Adjusted module rail styling and interactive states for consistent UI across the admin
- Updated ctaBanners type definition to include missing sortOrder, createdAt, and updatedAt fields
- Updated API and admin documentation to cover the new modules' CRUD endpoints and field contracts
This commit is contained in:
duanshuwen
2026-07-03 20:26:27 +08:00
parent 9fd91c84e5
commit 86a12dd1a0
9 changed files with 553 additions and 191 deletions

View File

@@ -29,6 +29,7 @@ import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSecti
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 {
@@ -53,6 +54,13 @@ import type {
} 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;
@@ -108,11 +116,23 @@ const pageSpecs: {
hint: "按运营任务新增精选线路分组,维护标题、副文案、启用状态和关联线路顺序。",
frontPosition: "首页精选线路",
},
{
id: "hotelGroups",
label: "特色酒店",
hint: "维护首页特色酒店卡片标题、描述、封面图、启用状态和排序。",
frontPosition: "首页「特色酒店」",
},
{
id: "vehicleOptions",
label: "万趣用车",
hint: "维护首页用车卡片标题、描述、封面图、启用状态和排序。",
frontPosition: "首页「万趣用车」",
},
{
id: "ctaBanners",
label: "底部运营入口",
hint: "控制权益卡、服务管家入口、目的地搜索、提交需求入口。",
frontPosition: "首页页尾 CTA",
label: "更多服务",
hint: "维护更多服务卡片标题、背景图、启用状态和排序。",
frontPosition: "首页「更多服务」",
},
],
},
@@ -170,9 +190,9 @@ const pageSpecs: {
},
{
id: "ctaBanners",
label: "营销 CTA",
hint: "页尾活动和需求入口。",
frontPosition: "首页底部运营区",
label: "更多服务入口",
hint: "维护更多服务里的活动和需求入口。",
frontPosition: "首页「更多服务」",
},
{
id: "campaignProducts",
@@ -232,6 +252,7 @@ export function StructurePage({
: [];
const isMapModule = moduleId === "map";
const isCampaignModule = moduleId === "campaigns";
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
const canReorderSiteItems =
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule;
const canCreateSiteItem =
@@ -602,18 +623,26 @@ export function StructurePage({
</div>
<h3></h3>
{activePage.modules.map((module, index) => (
<Button
variant="ghost"
className={activeModule.id === module.id ? "active" : ""}
<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>
</Button>
</div>
))}
</section>
<section className="item-rail">
@@ -722,7 +751,9 @@ export function StructurePage({
<p className="mapped-empty">
{isMapModule
? "暂无地图图片,点击上传地图。"
: "暂无配置数据,点击新增创建第一项。"}
: moduleId === "ctaBanners"
? "暂无更多服务卡片,点击新增创建第一项。"
: "暂无配置数据,点击新增创建第一项。"}
</p>
) : null}
</div>
@@ -898,21 +929,22 @@ function SiteItemEditor({
onClose: () => void;
}) {
const primaryKey = siteItemPrimaryKey(moduleId);
const primaryLabel =
moduleId === "heroSlides"
? "轮播标题"
: moduleId === "destinations"
? "目的地名称"
: moduleId === "map"
? "地图图片"
: moduleId === "themes"
? "主题名称"
: moduleId === "campaigns"
? "活动标题"
: "入口文案";
const primaryLabel = (() => {
if (moduleId === "heroSlides") return "轮播标题";
if (moduleId === "destinations") 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 isMoreServicesModule = moduleId === "ctaBanners";
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
const editorHint = isImageOnlyModule
? isCreating
? `上传后会作为「${moduleLabel}」唯一展示图片`
@@ -921,6 +953,10 @@ function SiteItemEditor({
? isCreating
? `创建后会进入「${moduleLabel}」的活动列表`
: `保存后会影响「${moduleLabel}」活动入口`
: isMoreServicesModule
? isCreating
? "创建后会进入首页「更多服务」卡片列表"
: "保存后会影响首页「更多服务」卡片"
: isCreating
? `创建后会进入「${moduleLabel}」的数据列表`
: `保存后会影响「${moduleLabel}」前台模块`;
@@ -988,10 +1024,53 @@ function SiteItemEditor({
/>
</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}
{isCampaignModule ? (
<AdminDisclosure title="价格信息">
<div className="form-grid compact-grid">
@@ -1030,7 +1109,7 @@ function SiteItemEditor({
/>
</AdminDisclosure>
) : null}
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : "资源图片"}>
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : isMoreServicesModule ? "服务卡片背景图" : "资源图片"}>
<SingleImageUploader
value={isCampaignModule ? draft.coverImage : draft.image}
onChange={(image) =>