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

@@ -1,7 +1,7 @@
import { Plus, X } from "lucide-react";
import { MutableRefObject, useEffect, useState } from "react";
import type { ProductInput } from "@/api";
import type { Product, ProductInput } from "@/api";
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
@@ -21,32 +21,57 @@ type QuickRouteDraft = {
sortWeight: number;
};
const emptyDraft: QuickRouteDraft = {
title: "",
summary: "",
destinationName: "",
priceAmount: null,
priceUnit: "起/人",
tags: ["", ""],
coverImage: "",
published: true,
sortWeight: 0,
};
function createEmptyDraft(): QuickRouteDraft {
return {
title: "",
summary: "",
destinationName: "",
priceAmount: null,
priceUnit: "起/人",
tags: ["", ""],
coverImage: "",
published: true,
sortWeight: 0,
};
}
function createDraftFromProduct(product?: Product | null): QuickRouteDraft {
if (!product) return createEmptyDraft();
const tags = product.tags.length ? product.tags.slice(0, 3) : ["", ""];
return {
title: product.title,
summary: product.summary ?? "",
destinationName: product.subtitle ?? product.destination?.name ?? "",
priceAmount: product.priceAmount ?? null,
priceUnit: product.priceUnit || "起/人",
tags: tags.length === 1 ? [...tags, ""] : tags,
coverImage: product.coverImage ?? "",
published: product.status === "published",
sortWeight: product.sortWeight ?? 0,
};
}
export function RouteProductQuickCreateForm({
product,
onCreate,
onUpdate,
onSubmitRef,
}: {
product?: Product | null;
onCreate: (input: ProductInput) => Promise<void>;
onUpdate: (productId: string, input: ProductInput) => Promise<void>;
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
}) {
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
const [draft, setDraft] = useState<QuickRouteDraft>(() => createDraftFromProduct(product));
const editing = Boolean(product);
const submit = async () => {
const title = draft.title.trim();
if (!title) return;
await onCreate({
const input: ProductInput = {
title,
subtitle: draft.destinationName.trim(),
destinationId: null,
@@ -55,20 +80,31 @@ export function RouteProductQuickCreateForm({
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
coverImage: draft.coverImage.trim() || null,
summary: draft.summary.trim(),
images: [],
detailSections: [],
images: product?.images ?? [],
detailSections: product?.detailSections ?? [],
status: draft.published ? "published" : "draft",
sortWeight: draft.sortWeight,
});
setDraft(emptyDraft);
};
if (editing && product) {
await onUpdate(product.id, input);
return;
}
await onCreate(input);
setDraft(createEmptyDraft());
};
useEffect(() => {
setDraft(createDraftFromProduct(product));
}, [product?.id, product?.updatedAt]);
useEffect(() => {
onSubmitRef.current = submit;
return () => {
onSubmitRef.current = null;
};
}, [draft, onSubmitRef]);
}, [draft, product?.id, product?.updatedAt, onSubmitRef]);
return (
<div className="admin-disclosure-stack route-product-create-form">
@@ -153,7 +189,7 @@ export function RouteProductQuickCreateForm({
checked={draft.published}
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
/>
{editing ? "保存为上架" : "创建后上架"}
</label>
</AdminDisclosure>
</div>