feat: add campaigns module with CRUD operations and UI integration

This commit is contained in:
duanshuwen
2026-07-02 13:25:52 +08:00
parent 710d020504
commit 50be3cbfce
9 changed files with 467 additions and 63 deletions

View File

@@ -29,6 +29,7 @@ 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";
import {
compactSiteItemPayload,
createEmptySiteItemDraft,
@@ -36,6 +37,7 @@ import {
itemMeta,
itemName,
itemSortOrder,
itemTags,
moduleEditable,
moduleItems,
siteItemPrimaryKey,
@@ -93,6 +95,12 @@ const pageSpecs: {
hint: "控制横向主题卡和搜索跳转。",
frontPosition: "首页「主题甄选」",
},
{
id: "campaigns",
label: "特价优惠",
hint: "维护活动标题、封面和发布状态,供首页特价优惠入口使用。",
frontPosition: "首页「特价优惠」",
},
{
id: "routeProducts",
label: "精选线路",
@@ -218,6 +226,9 @@ export function StructurePage({
const editableItems =
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
const isMapModule = moduleId === "map";
const isCampaignModule = moduleId === "campaigns";
const canReorderSiteItems =
moduleEditable(moduleId) && !isMapModule && !isCampaignModule;
const canCreateSiteItem =
moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0);
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
@@ -246,9 +257,17 @@ export function StructurePage({
label: "label" in item ? item.label : undefined,
alt: "alt" in item ? item.alt : undefined,
image: "image" in item ? item.image || "" : "",
description: "description" in item ? item.description || "" : undefined,
coverImage: "coverImage" in item ? item.coverImage || "" : undefined,
priceAmount: "priceAmount" in item ? item.priceAmount : undefined,
priceUnit: "priceUnit" in item ? item.priceUnit || "起/人" : undefined,
tags: "tags" in item ? item.tags.slice(0, 3) : undefined,
isHot: "isHot" in item ? item.isHot : undefined,
isActive: item.isActive,
isActive: "isActive" in item ? item.isActive : undefined,
sortOrder: itemSortOrder(item),
status: "status" in item && (item.status === "draft" || item.status === "published") ? item.status : undefined,
startsAt: "startsAt" in item ? item.startsAt || "" : undefined,
endsAt: "endsAt" in item ? item.endsAt || "" : undefined,
});
};
@@ -367,11 +386,12 @@ export function StructurePage({
const messageText =
moduleId === "map"
? "请先上传贵州地图图片。"
: moduleId === "campaigns"
? "请先填写特价优惠标题。"
: "请先填写当前模块的主标题/名称。";
notify({ tone: "warning", title: "内容未保存", message: messageText });
return;
}
setSaving(true);
setMessage("");
@@ -621,17 +641,24 @@ export function StructurePage({
<b>{itemName(item)}</b>
<small>
{itemMeta(item)}
{!isMapModule && typeof itemSortOrder(item) === "number"
{canReorderSiteItems && typeof itemSortOrder(item) === "number"
? ` · 排序 ${itemSortOrder(item)}`
: ""}
</small>
{itemTags(item).length ? (
<span className="mapped-list-tags">
{itemTags(item).map((tag) => (
<em key={tag}>{tag}</em>
))}
</span>
) : null}
</span>
</Button>
<div
className="mapped-list-actions"
aria-label={`${itemName(item)} 操作`}
>
{!isMapModule ? (
{canReorderSiteItems ? (
<>
<Button
variant="ghost"
@@ -715,6 +742,67 @@ export function StructurePage({
);
}
function CampaignTagEditor({
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>
);
}
function ReferencePanel({
moduleId,
products,
@@ -792,13 +880,20 @@ function SiteItemEditor({
? "地图图片"
: moduleId === "themes"
? "主题名称"
: moduleId === "campaigns"
? "活动标题"
: "入口文案";
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
const isImageOnlyModule = moduleId === "map";
const isCampaignModule = moduleId === "campaigns";
const editorHint = isImageOnlyModule
? isCreating
? `上传后会作为「${moduleLabel}」唯一展示图片`
: `保存后会替换「${moduleLabel}」前台展示图片`
: isCampaignModule
? isCreating
? `创建后会进入「${moduleLabel}」的活动列表`
: `保存后会影响「${moduleLabel}」活动入口`
: isCreating
? `创建后会进入「${moduleLabel}」的数据列表`
: `保存后会影响「${moduleLabel}」前台模块`;
@@ -822,68 +917,152 @@ function SiteItemEditor({
<div className="admin-disclosure-stack">
{!isImageOnlyModule ? (
<AdminDisclosure title="展示内容">
<label>
{primaryLabel}
<Input
value={String(draft[primaryKey] ?? "")}
onChange={(event) =>
onDraft({ ...draft, [primaryKey]: event.target.value })
}
/>
</label>
{moduleId === "heroSlides" ? (
{isCampaignModule ? (
<>
<label>
{primaryLabel}
<Input
value={draft.title ?? ""}
onChange={(event) =>
onDraft({ ...draft, title: event.target.value })
}
/>
</label>
<label>
<Textarea
value={draft.description ?? ""}
onChange={(event) =>
onDraft({ ...draft, description: event.target.value })
}
rows={3}
/>
</label>
</>
) : (
<>
<label>
{primaryLabel}
<Input
value={String(draft[primaryKey] ?? "")}
onChange={(event) =>
onDraft({ ...draft, [primaryKey]: event.target.value })
}
/>
</label>
{moduleId === "heroSlides" ? (
<label>
<Input
value={draft.kicker ?? ""}
onChange={(event) =>
onDraft({ ...draft, kicker: event.target.value })
}
/>
</label>
) : null}
</>
)}
</AdminDisclosure>
) : null}
{isCampaignModule ? (
<AdminDisclosure title="价格信息">
<div className="form-grid compact-grid">
<label>
<Input
value={draft.kicker ?? ""}
type="number"
value={draft.priceAmount ?? ""}
onChange={(event) =>
onDraft({ ...draft, kicker: event.target.value })
onDraft({
...draft,
priceAmount: event.target.value
? Number(event.target.value)
: null,
})
}
/>
</label>
) : null}
<label>
<Input
value={draft.priceUnit ?? "起/人"}
onChange={(event) =>
onDraft({ ...draft, priceUnit: event.target.value })
}
/>
</label>
</div>
</AdminDisclosure>
) : null}
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : "资源图片"}>
{isCampaignModule ? (
<AdminDisclosure title="活动标签">
<CampaignTagEditor
tags={draft.tags ?? []}
onChange={(tags) => onDraft({ ...draft, tags })}
/>
</AdminDisclosure>
) : null}
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : "资源图片"}>
<SingleImageUploader
value={draft.image}
onChange={(image) => onDraft({ ...draft, image })}
value={isCampaignModule ? draft.coverImage : draft.image}
onChange={(image) =>
onDraft(
isCampaignModule
? { ...draft, coverImage: image }
: { ...draft, image },
)
}
group={moduleId}
/>
</AdminDisclosure>
<AdminDisclosure title={isImageOnlyModule ? "显示状态" : "排序与状态"}>
{!isImageOnlyModule ? (
<label>
<Input
type="number"
value={draft.sortOrder ?? 0}
onChange={(event) =>
onDraft({ ...draft, sortOrder: Number(event.target.value) })
}
/>
</label>
) : null}
{moduleId === "destinations" ? (
<AdminDisclosure title={isImageOnlyModule || isCampaignModule ? "显示状态" : "排序与状态"}>
{isCampaignModule ? (
<label className="switch-line">
<Switch
checked={Boolean(draft.isHot)}
checked={draft.status === "published"}
onCheckedChange={(checked) =>
onDraft({ ...draft, isHot: checked })
onDraft({ ...draft, status: checked ? "published" : "draft" })
}
/>
</label>
) : null}
<label className="switch-line">
<Switch
checked={Boolean(draft.isActive)}
onCheckedChange={(checked) =>
onDraft({ ...draft, isActive: checked })
}
/>
</label>
) : (
<>
{!isImageOnlyModule ? (
<label>
<Input
type="number"
value={draft.sortOrder ?? 0}
onChange={(event) =>
onDraft({ ...draft, sortOrder: Number(event.target.value) })
}
/>
</label>
) : null}
{moduleId === "destinations" ? (
<label className="switch-line">
<Switch
checked={Boolean(draft.isHot)}
onCheckedChange={(checked) =>
onDraft({ ...draft, isHot: checked })
}
/>
</label>
) : null}
<label className="switch-line">
<Switch
checked={Boolean(draft.isActive)}
onCheckedChange={(checked) =>
onDraft({ ...draft, isActive: checked })
}
/>
</label>
</>
)}
</AdminDisclosure>
</div>
<footer className="drawer-editor-footer">