feat(admin): add route sections admin management module
This commit adds the complete admin workflow for managing homepage curated route subgroups: - Add `RouteSection` type and extend `SiteConfig`/`SiteModule` to support the new module - Create all required admin components: panel, editors, product picker, and utility functions - Update admin utilities and API types to handle routeSections CRUD operations - Update documentation to include the new routeSections API contract - Add supporting CSS styles for the new UI elements
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { 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;
|
||||
};
|
||||
|
||||
const emptyDraft: QuickRouteDraft = {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
|
||||
export function RouteProductQuickCreateForm({
|
||||
onCreate,
|
||||
onSubmitRef,
|
||||
}: {
|
||||
onCreate: (input: ProductInput) => Promise<void>;
|
||||
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
|
||||
|
||||
const submit = async () => {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
|
||||
await onCreate({
|
||||
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: [],
|
||||
detailSections: [],
|
||||
status: draft.published ? "published" : "draft",
|
||||
sortWeight: draft.sortWeight,
|
||||
});
|
||||
setDraft(emptyDraft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = submit;
|
||||
return () => {
|
||||
onSubmitRef.current = null;
|
||||
};
|
||||
}, [draft, 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 })}
|
||||
/>
|
||||
创建后上架
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user