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:
duanshuwen
2026-07-02 23:05:45 +08:00
parent 50be3cbfce
commit 9fd91c84e5
14 changed files with 1732 additions and 21 deletions

View File

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