Files
WonderQ-Admin-UI/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx
duanshuwen 86a12dd1a0 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
2026-07-03 20:26:27 +08:00

252 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}