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
252 lines
7.2 KiB
TypeScript
252 lines
7.2 KiB
TypeScript
import { Plus, X } from "lucide-react";
|
||
import { MutableRefObject, useEffect, useState } from "react";
|
||
|
||
import type { Product, ProductInput } from "@/api";
|
||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||
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 { Textarea } from "@/components/ui/textarea";
|
||
|
||
type QuickRouteDraft = {
|
||
title: string;
|
||
summary: string;
|
||
destinationName: string;
|
||
priceAmount: number | null;
|
||
priceUnit: string;
|
||
tags: string[];
|
||
coverImage: string;
|
||
published: boolean;
|
||
sortWeight: number;
|
||
};
|
||
|
||
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>(() => createDraftFromProduct(product));
|
||
const editing = Boolean(product);
|
||
|
||
const submit = async () => {
|
||
const title = draft.title.trim();
|
||
if (!title) return;
|
||
|
||
const input: ProductInput = {
|
||
title,
|
||
subtitle: draft.destinationName.trim(),
|
||
destinationId: null,
|
||
priceAmount: draft.priceAmount,
|
||
priceUnit: draft.priceUnit.trim() || "起/人",
|
||
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
|
||
coverImage: draft.coverImage.trim() || null,
|
||
summary: draft.summary.trim(),
|
||
images: product?.images ?? [],
|
||
detailSections: product?.detailSections ?? [],
|
||
status: draft.published ? "published" : "draft",
|
||
sortWeight: draft.sortWeight,
|
||
};
|
||
|
||
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, product?.id, product?.updatedAt, onSubmitRef]);
|
||
|
||
return (
|
||
<div className="admin-disclosure-stack route-product-create-form">
|
||
<AdminDisclosure title="展示内容">
|
||
<label>
|
||
线路标题
|
||
<Input
|
||
value={draft.title}
|
||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||
/>
|
||
</label>
|
||
<label>
|
||
线路描述
|
||
<Textarea
|
||
value={draft.summary}
|
||
onChange={(event) => setDraft({ ...draft, summary: event.target.value })}
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
<label>
|
||
目标地点
|
||
<Input
|
||
value={draft.destinationName}
|
||
onChange={(event) => setDraft({ ...draft, destinationName: event.target.value })}
|
||
/>
|
||
</label>
|
||
</AdminDisclosure>
|
||
|
||
<AdminDisclosure title="价格信息">
|
||
<div className="form-grid compact-grid">
|
||
<label>
|
||
价格
|
||
<Input
|
||
type="number"
|
||
value={draft.priceAmount ?? ""}
|
||
onChange={(event) =>
|
||
setDraft({
|
||
...draft,
|
||
priceAmount: event.target.value ? Number(event.target.value) : null,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
价格单位
|
||
<Input
|
||
value={draft.priceUnit}
|
||
onChange={(event) => setDraft({ ...draft, priceUnit: event.target.value })}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</AdminDisclosure>
|
||
|
||
<AdminDisclosure title="线路标签">
|
||
<QuickTagEditor
|
||
tags={draft.tags}
|
||
onChange={(tags) => setDraft({ ...draft, tags })}
|
||
/>
|
||
</AdminDisclosure>
|
||
|
||
<AdminDisclosure title="线路封面图">
|
||
<SingleImageUploader
|
||
value={draft.coverImage}
|
||
onChange={(coverImage) => setDraft({ ...draft, coverImage: coverImage ?? "" })}
|
||
group="route-section-products"
|
||
/>
|
||
</AdminDisclosure>
|
||
|
||
<AdminDisclosure title="显示状态">
|
||
<label>
|
||
排序值
|
||
<Input
|
||
type="number"
|
||
value={draft.sortWeight}
|
||
onChange={(event) =>
|
||
setDraft({ ...draft, sortWeight: Number(event.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="switch-line">
|
||
<Switch
|
||
checked={draft.published}
|
||
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
|
||
/>
|
||
{editing ? "保存为上架" : "创建后上架"}
|
||
</label>
|
||
</AdminDisclosure>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuickTagEditor({
|
||
tags,
|
||
onChange,
|
||
}: {
|
||
tags: string[];
|
||
onChange: (tags: string[]) => void;
|
||
}) {
|
||
const normalizedTags = tags.slice(0, 3);
|
||
const updateTag = (index: number, value: string) => {
|
||
onChange(normalizedTags.map((tag, tagIndex) => (tagIndex === index ? value : tag)));
|
||
};
|
||
const removeTag = (index: number) => {
|
||
onChange(normalizedTags.filter((_, tagIndex) => tagIndex !== index));
|
||
};
|
||
const addTag = () => {
|
||
if (normalizedTags.length >= 3) return;
|
||
onChange([...normalizedTags, ""]);
|
||
};
|
||
|
||
return (
|
||
<div className="tag-editor compact-tag-editor">
|
||
{normalizedTags.map((tag, index) => (
|
||
<label className="tag-input-row" key={`${index}-${tag}`}>
|
||
标签 {index + 1}
|
||
<span>
|
||
<Input value={tag} onChange={(event) => updateTag(index, event.target.value)} />
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="icon-action danger-icon"
|
||
onClick={() => removeTag(index)}
|
||
aria-label={`删除标签 ${index + 1}`}
|
||
>
|
||
<X size={16} />
|
||
</Button>
|
||
</span>
|
||
</label>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="inline-action"
|
||
onClick={addTag}
|
||
disabled={normalizedTags.length >= 3}
|
||
>
|
||
<Plus size={16} />
|
||
添加标签
|
||
</Button>
|
||
<p className="field-help">最多维护 3 个标签,保存时会自动去掉空标签。</p>
|
||
</div>
|
||
);
|
||
}
|