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>

View File

@@ -3,60 +3,39 @@ import { useRef } from "react";
import type { Product, ProductInput } from "@/api";
import { RouteProductQuickCreateForm } from "@/components/admin/route-sections/RouteProductQuickCreateForm";
import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker";
import type {
ProductUsage,
RouteSectionDraft,
} from "@/components/admin/route-sections/helpers";
import type { RouteSectionDraft } from "@/components/admin/route-sections/helpers";
import { Button } from "@/components/ui/button";
export function RouteSectionProductEditor({
draft,
products,
productUsage,
product,
saving,
onCreateProduct,
onProductIdsChange,
onUpdateProduct,
onClose,
}: {
draft: RouteSectionDraft;
products: Product[];
productUsage: Map<string, ProductUsage>;
product?: Product | null;
saving: boolean;
onCreateProduct: (input: ProductInput) => Promise<void>;
onProductIdsChange: (productIds: string[]) => Promise<void>;
onUpdateProduct: (productId: string, input: ProductInput) => Promise<void>;
onClose: () => void;
}) {
const createSubmitRef = useRef<(() => Promise<void>) | null>(null);
const updateProductIds = (productIds: string[]) => {
void onProductIdsChange(productIds);
};
const addProduct = (productId: string) => {
if (draft.productIds.includes(productId)) return;
updateProductIds([...draft.productIds, productId]);
};
const removeProduct = (productId: string) => {
updateProductIds(draft.productIds.filter((candidate) => candidate !== productId));
};
const moveProduct = (productId: string, direction: -1 | 1) => {
const currentIndex = draft.productIds.indexOf(productId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= draft.productIds.length) return;
const nextProductIds = [...draft.productIds];
[nextProductIds[currentIndex], nextProductIds[nextIndex]] = [
nextProductIds[nextIndex],
nextProductIds[currentIndex],
];
updateProductIds(nextProductIds);
};
const editing = Boolean(product);
return (
<div className="mapped-editor route-section-editor">
<header className="editor-head">
<span>
<h3 id="route-section-product-editor-title">线</h3>
<small>{draft.title} 线</small>
<h3 id="route-section-product-editor-title">
{editing ? "编辑线路" : "关联线路"}
</h3>
<small>
{editing
? `${draft.title} 的首页展示线路内容。`
: `${draft.title} 的首页展示线路,创建后会自动关联到当前分组。`}
</small>
</span>
<Button
variant="ghost"
@@ -68,17 +47,11 @@ export function RouteSectionProductEditor({
</Button>
</header>
<RouteProductQuickCreateForm
product={product}
onCreate={onCreateProduct}
onUpdate={onUpdateProduct}
onSubmitRef={createSubmitRef}
/>
<RouteSectionProductPicker
products={products}
productIds={draft.productIds}
productUsage={productUsage}
onAdd={addProduct}
onRemove={removeProduct}
onMove={moveProduct}
/>
<footer className="drawer-editor-footer">
<Button variant="outline" onClick={onClose} disabled={saving}>
@@ -89,7 +62,7 @@ export function RouteSectionProductEditor({
disabled={saving}
>
<Save size={17} />
{saving ? "创建中" : "创建并关联"}
{saving ? (editing ? "保存中" : "创建中") : editing ? "保存线路" : "创建并关联"}
</Button>
</footer>
</div>

View File

@@ -7,12 +7,12 @@ import {
createSiteConfigItem,
deleteSiteConfigItem,
reorderSiteConfigItems,
updateProduct,
updateSiteConfigItem,
} from "@/api";
import type { ProductInput } from "@/api";
import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor";
import {
buildProductUsageMap,
compactRouteSectionDraft,
createEmptyRouteSectionDraft,
createRouteSectionDraft,
@@ -46,13 +46,17 @@ export function RouteSectionsPanel({
const [draft, setDraft] = useState<RouteSectionDraft | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [productEditorOpen, setProductEditorOpen] = useState(false);
const [editingProductId, setEditingProductId] = useState("");
const [creating, setCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const createRequestSeen = useRef(createRequestKey);
const sortedSections = useMemo(
() => [...routeSections].sort((left, right) => left.sortOrder - right.sortOrder),
() =>
[...routeSections].sort(
(left, right) => left.sortOrder - right.sortOrder,
),
[routeSections],
);
const productById = useMemo(
@@ -62,10 +66,9 @@ export function RouteSectionsPanel({
const selectedSection =
sortedSections.find((section) => section.id === selectedId) ??
sortedSections[0];
const productUsage = useMemo(
() => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""),
[draft?.id, routeSections, selectedSection?.id],
);
const editingProduct = editingProductId
? (productById.get(editingProductId) ?? null)
: null;
useEffect(() => {
const firstSection = sortedSections[0];
@@ -75,18 +78,23 @@ export function RouteSectionsPanel({
setDraft(null);
setEditorOpen(false);
setProductEditorOpen(false);
setEditingProductId("");
return;
}
const nextSection =
sortedSections.find((section) => section.id === selectedId) ?? firstSection;
sortedSections.find((section) => section.id === selectedId) ??
firstSection;
setSelectedId(nextSection.id);
setExpandedId((current) =>
sortedSections.some((section) => section.id === current) ? current : nextSection.id,
sortedSections.some((section) => section.id === current)
? current
: nextSection.id,
);
setDraft(createRouteSectionDraft(nextSection));
setEditorOpen(false);
setProductEditorOpen(false);
setEditingProductId("");
setCreating(false);
setMessage("");
}, [routeSections]);
@@ -99,6 +107,7 @@ export function RouteSectionsPanel({
setDraft(createEmptyRouteSectionDraft(sortedSections.length));
setEditorOpen(true);
setProductEditorOpen(false);
setEditingProductId("");
setCreating(true);
setMessage("");
onDirtyChange(false);
@@ -111,14 +120,16 @@ export function RouteSectionsPanel({
setMessage("");
setEditorOpen(true);
setProductEditorOpen(false);
setEditingProductId("");
setCreating(false);
onDirtyChange(false);
};
const openProductEditor = (section: RouteSection) => {
const openProductEditor = (section: RouteSection, product?: Product) => {
setSelectedId(section.id);
setExpandedId(section.id);
setDraft(createRouteSectionDraft(section));
setEditingProductId(product?.id ?? "");
setMessage("");
setEditorOpen(false);
setProductEditorOpen(true);
@@ -132,6 +143,7 @@ export function RouteSectionsPanel({
}
setEditorOpen(false);
setProductEditorOpen(false);
setEditingProductId("");
setCreating(false);
setMessage("");
onDirtyChange(false);
@@ -190,40 +202,6 @@ export function RouteSectionsPanel({
}
};
const updateProductLinks = async (productIds: string[]) => {
if (!draft || saving || !draft.id) return;
setSaving(true);
setMessage("");
try {
const nextDraft = { ...draft, productIds: Array.from(new Set(productIds)) };
setDraft(nextDraft);
await updateSiteConfigItem(
"routeSections",
draft.id,
compactRouteSectionDraft(nextDraft),
);
notify({
tone: "success",
title: "关联线路已保存",
message: `${draft.title} 的关联线路已更新。`,
});
onDirtyChange(false);
await onReload();
} catch (err) {
const messageText = err instanceof Error ? err.message : "保存失败";
setMessage(messageText);
notify({
tone: "danger",
title: "关联线路保存失败",
message: messageText,
});
} finally {
setSaving(false);
}
};
const createAndLinkProduct = async (input: ProductInput) => {
if (!draft || saving || !draft.id) return;
@@ -263,10 +241,94 @@ export function RouteSectionsPanel({
}
};
const saveLinkedProduct = async (productId: string, input: ProductInput) => {
if (saving) return;
setSaving(true);
setMessage("");
try {
const product = await updateProduct(
productId,
compactProductPayload(input),
);
setEditingProductId(product.id);
notify({
tone: "success",
title: "线路已保存",
message: `${product.title} 已更新。`,
});
onDirtyChange(false);
await onReload();
} catch (err) {
const messageText = err instanceof Error ? err.message : "保存失败";
setMessage(messageText);
notify({
tone: "danger",
title: "线路保存失败",
message: messageText,
});
} finally {
setSaving(false);
}
};
const unlinkProductFromSection = async (
section: RouteSection,
product: Product,
) => {
if (saving) return;
const confirmed = window.confirm(
`确认从「${section.title}」移除「${product.title}」?商品本体不会被删除。`,
);
if (!confirmed) return;
setSaving(true);
setMessage("");
try {
const nextDraft = {
...createRouteSectionDraft(section),
productIds: section.productIds.filter(
(productId) => productId !== product.id,
),
};
await updateSiteConfigItem(
"routeSections",
section.id,
compactRouteSectionDraft(nextDraft),
);
if (draft?.id === section.id) {
setDraft(nextDraft);
}
notify({
tone: "success",
title: "关联线路已移除",
message: `${product.title} 已从 ${section.title} 中移除。`,
});
onDirtyChange(false);
await onReload();
} catch (err) {
const messageText = err instanceof Error ? err.message : "移除失败";
setMessage(messageText);
notify({
tone: "danger",
title: "关联线路移除失败",
message: messageText,
});
} finally {
setSaving(false);
}
};
const moveSection = async (section: RouteSection, direction: -1 | 1) => {
const currentIndex = sortedSections.findIndex((candidate) => candidate.id === section.id);
const currentIndex = sortedSections.findIndex(
(candidate) => candidate.id === section.id,
);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length)
return;
const nextIds = sortedSections.map((candidate) => candidate.id);
[nextIds[currentIndex], nextIds[nextIndex]] = [
@@ -317,6 +379,7 @@ export function RouteSectionsPanel({
setDraft(null);
setEditorOpen(false);
setProductEditorOpen(false);
setEditingProductId("");
setCreating(false);
onDirtyChange(false);
await onReload();
@@ -345,7 +408,10 @@ export function RouteSectionsPanel({
<div className="route-section-card-list">
{sortedSections.map((section, index) => {
const previewProducts = sectionProducts(section);
const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length);
const missingProductCount = Math.max(
0,
section.productIds.length - previewProducts.length,
);
return (
<article
@@ -353,54 +419,91 @@ export function RouteSectionsPanel({
key={section.id}
>
<div className="route-section-accordion-head">
<button
type="button"
<div
className="route-section-accordion-toggle"
onClick={() =>
setExpandedId((current) => (current === section.id ? "" : section.id))
setExpandedId((current) =>
current === section.id ? "" : section.id,
)
}
>
<span className="route-section-order">
<Route size={18} />
{index + 1}
</span>
<span className="route-section-card-copy">
<span className="route-section-card-titleline">
<b>{section.title}</b>
</span>
<small>{section.subtitle || "未填写副文案"}</small>
<span className="route-section-card-copy">
<span className="route-section-card-titleline">
<b>{section.title}</b>
</span>
</button>
<small>{section.subtitle || "未填写副文案"}</small>
</span>
</div>
</div>
{expandedId === section.id ? (
<div className="route-section-accordion-body">
<div className="route-section-preview">
{previewProducts.slice(0, 3).map((product) => (
<span key={product.id}>
{previewProducts.map((product) => (
<div
className="route-section-preview-item"
key={product.id}
>
{product.coverImage ? (
<img src={product.coverImage} alt="" />
) : (
<i />
)}
<b>{product.title}</b>
</span>
<b title={product.title}>{product.title}</b>
<span className="route-section-preview-actions">
<Button
type="button"
variant="ghost"
className="route-section-preview-edit"
onClick={() =>
openProductEditor(section, product)
}
>
<Pencil size={12} />
</Button>
<Button
type="button"
variant="ghost"
className="route-section-preview-delete"
onClick={() =>
unlinkProductFromSection(section, product)
}
>
<Trash2 size={12} />
</Button>
</span>
</div>
))}
{!previewProducts.length ? (
<p>线</p>
) : null}
{!previewProducts.length ? <p>线</p> : null}
{missingProductCount ? (
<p>{missingProductCount} </p>
<p>
{missingProductCount}{" "}
</p>
) : null}
</div>
</div>
) : null}
<footer className="route-section-card-actions">
<Badge
variant={section.isActive ? "success" : "muted"}
className={`status-chip ${section.isActive ? "published" : "archived"}`}
>
{section.isActive ? "已启用" : "已停用"}
</Badge>
<div className="flex min-w-0 items-center gap-2">
<Badge
variant={section.isActive ? "success" : "muted"}
className={`status-chip ${section.isActive ? "published" : "archived"}`}
>
{section.isActive ? "已启用" : "已停用"}
</Badge>
<Badge
variant={section.productIds.length > 0 ? "success" : "muted"}
className="shrink-0"
>
{section.productIds.length} 线
</Badge>
</div>
<div className="route-section-card-action-tools">
<div className="route-section-card-primary-actions">
<Button
@@ -460,9 +563,7 @@ export function RouteSectionsPanel({
</p>
)}
{message ? (
<p className="mapped-empty route-section-message">
{message}
</p>
<p className="mapped-empty route-section-message">{message}</p>
) : null}
</div>
{editorOpen && draft ? (
@@ -506,11 +607,10 @@ export function RouteSectionsPanel({
>
<RouteSectionProductEditor
draft={draft}
products={products}
productUsage={productUsage}
product={editingProduct}
saving={saving}
onCreateProduct={createAndLinkProduct}
onProductIdsChange={updateProductLinks}
onUpdateProduct={saveLinkedProduct}
onClose={closeEditor}
/>
</section>