feat(admin): add demand lead management and site config modules

- Add lead list page with keyword, date range and source filtering, plus per-row lead status updates
- Extend site configuration types and admin editor to support demand page modules: hero, feature cards, contact form and product recommendations
- Update admin navigation, tab handling and type definitions for the new lead list tab and demand modules
This commit is contained in:
duanshuwen
2026-07-07 22:03:52 +08:00
parent 3b3d8d0aa1
commit 5b089875b3
6 changed files with 485 additions and 20 deletions

View File

@@ -45,6 +45,54 @@ export type DestinationRegion = {
updatedAt?: string;
};
export type DemandHero = {
id: string;
title: string;
kicker?: string | null;
description?: string | null;
steps: string[];
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
export type DemandFeatureCard = {
id: string;
title: string;
description?: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
export type DemandForm = {
id: string;
destinationLabel: string;
destinationPlaceholder?: string | null;
phoneLabel: string;
phonePlaceholder?: string | null;
noteLabel: string;
notePlaceholder?: string | null;
submitLabel: string;
chips: string[];
isActive: boolean;
createdAt?: string;
updatedAt?: string;
};
export type DemandRecommendation = {
id: string;
title: string;
subtitle?: string | null;
productIds: string[];
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
export type Product = {
id: string;
sourceId?: number | null;
@@ -95,6 +143,10 @@ export type Lead = {
export type LeadListParams = {
status?: LeadStatus;
sourcePage?: string;
keyword?: string;
createdFrom?: string;
createdTo?: string;
take?: number;
};
@@ -168,6 +220,10 @@ export type SiteConfig = {
destinations: Destination[];
destinationHero: DestinationHero[];
destinationRegions: DestinationRegion[];
demandHero: DemandHero[];
demandFeatureCards: DemandFeatureCard[];
demandForm: DemandForm[];
demandRecommendations: DemandRecommendation[];
map: Array<{ id: string; image: string | null; isActive: boolean; createdAt?: string; updatedAt?: string }>;
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
campaigns: Campaign[];
@@ -177,7 +233,22 @@ export type SiteConfig = {
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
};
export type SiteModule = "heroSlides" | "destinations" | "destinationHero" | "destinationRegions" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
export type SiteModule =
| "heroSlides"
| "destinations"
| "destinationHero"
| "destinationRegions"
| "demandHero"
| "demandFeatureCards"
| "demandForm"
| "demandRecommendations"
| "map"
| "themes"
| "campaigns"
| "routeSections"
| "hotelGroups"
| "vehicleOptions"
| "ctaBanners";
export type SiteConfigItem = SiteConfig[SiteModule][number];
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
@@ -198,6 +269,15 @@ export type SiteItemPatch = {
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
steps?: string[];
destinationLabel?: string;
destinationPlaceholder?: string | null;
phoneLabel?: string;
phonePlaceholder?: string | null;
noteLabel?: string;
notePlaceholder?: string | null;
submitLabel?: string;
chips?: string[];
targetType?: string | null;
targetValue?: string | null;
isHot?: boolean;
@@ -354,6 +434,10 @@ export async function reorderSiteConfigItems(module: SiteModule, itemIds: string
export async function getLeads(params: LeadListParams = {}) {
const search = new URLSearchParams();
if (params.status) search.set("status", params.status);
if (params.sourcePage) search.set("sourcePage", params.sourcePage);
if (params.keyword) search.set("keyword", params.keyword);
if (params.createdFrom) search.set("createdFrom", params.createdFrom);
if (params.createdTo) search.set("createdTo", params.createdTo);
if (params.take) search.set("take", String(params.take));
const query = search.toString();
return request<{ items: Lead[] }>(`/api/admin/leads${query ? `?${query}` : ""}`);

View File

@@ -33,6 +33,9 @@ const sidebarGroups: { id: string; label: string; items: { id: Tab; label: strin
];
const tabs = sidebarGroups.flatMap((group) => group.items);
const hiddenTabs: Partial<Record<Tab, { label: string; description: string }>> = {
leadList: { label: "线索查询跟进", description: "表单查询和状态流转" },
};
export function AdminShell({ onLogout }: { onLogout: () => void }) {
const [activeTab, setActiveTab] = useState<Tab>("structure");
@@ -50,7 +53,7 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
setHasUnsavedChanges(false);
}, [activeTab]);
const activeMeta = useMemo(() => tabs.find((tab) => tab.id === activeTab), [activeTab]);
const activeMeta = useMemo(() => tabs.find((tab) => tab.id === activeTab) ?? hiddenTabs[activeTab], [activeTab]);
const activeLabel = activeMeta?.label ?? "";
const activeDescription = activeMeta?.description ?? "";
@@ -125,6 +128,8 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
<StructurePage key="destination-workbench" fixedPage="destination" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
) : activeTab === "products" ? (
<ProductsPage destinations={destinations} onDirtyChange={setHasUnsavedChanges} notify={notify} />
) : activeTab === "leads" ? (
<StructurePage key="demand-workbench" fixedPage="demand" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
) : (
<LeadsPage notify={notify} />
)}

View File

@@ -131,6 +131,8 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
if (moduleId === "destinations") return "name";
if (moduleId === "destinationHero") return "title";
if (moduleId === "destinationRegions") return "label";
if (moduleId === "demandForm") return "submitLabel";
if (moduleId === "demandHero" || moduleId === "demandFeatureCards" || moduleId === "demandRecommendations") return "title";
if (moduleId === "map") return "image";
if (moduleId === "themes") return "label";
if (moduleId === "campaigns") return "title";
@@ -165,6 +167,28 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
if (moduleId === "destinationRegions") {
return { label: "", keyword: "", spots: "", isActive: true, sortOrder };
}
if (moduleId === "demandHero") {
return { title: "", kicker: "", description: "", steps: [], isActive: true, sortOrder };
}
if (moduleId === "demandFeatureCards") {
return { title: "", description: "", isActive: true, sortOrder };
}
if (moduleId === "demandForm") {
return {
destinationLabel: "目的地/玩法",
destinationPlaceholder: "例如:贵州、黄果树、西江苗寨",
phoneLabel: "联系方式",
phonePlaceholder: "手机号 / 微信号",
noteLabel: "补充说明",
notePlaceholder: "出行日期、人数、酒店偏好、预算范围",
submitLabel: "提交出行需求",
chips: [],
isActive: true,
};
}
if (moduleId === "demandRecommendations") {
return { title: "", subtitle: "", productIds: [], isActive: true, sortOrder };
}
if (moduleId === "map") {
return { image: "", isActive: false };
}
@@ -246,6 +270,52 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "demandHero") {
payload.title = draft.title?.trim();
payload.kicker = draft.kicker?.trim() || null;
payload.description = draft.description?.trim() || null;
payload.steps = (draft.steps ?? []).map((step) => step.trim()).filter(Boolean);
payload.image = undefined;
payload.productIds = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "demandFeatureCards") {
payload.title = draft.title?.trim();
payload.description = draft.description?.trim() || null;
payload.image = undefined;
payload.productIds = undefined;
payload.kicker = undefined;
payload.subtitle = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "demandForm") {
payload.destinationLabel = draft.destinationLabel?.trim() || "目的地/玩法";
payload.destinationPlaceholder = draft.destinationPlaceholder?.trim() || null;
payload.phoneLabel = draft.phoneLabel?.trim() || "联系方式";
payload.phonePlaceholder = draft.phonePlaceholder?.trim() || null;
payload.noteLabel = draft.noteLabel?.trim() || "补充说明";
payload.notePlaceholder = draft.notePlaceholder?.trim() || null;
payload.submitLabel = draft.submitLabel?.trim() || "提交出行需求";
payload.chips = (draft.chips ?? []).map((chip) => chip.trim()).filter(Boolean);
payload.image = undefined;
payload.sortOrder = undefined;
payload.productIds = undefined;
payload.kicker = undefined;
payload.subtitle = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "demandRecommendations") {
payload.title = draft.title?.trim();
payload.subtitle = draft.subtitle?.trim() || null;
payload.productIds = Array.from(new Set((draft.productIds ?? []).filter(Boolean)));
payload.image = undefined;
payload.kicker = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "map") {
payload.sortOrder = undefined;
}
@@ -331,6 +401,7 @@ export function readImageAsDataUrl(file: File) {
export function itemName(item: EditableSiteItem) {
if ("title" in item) return item.title;
if ("submitLabel" in item) return "需求表单配置";
if ("name" in item) return item.name;
if ("label" in item) return item.label;
if ("alt" in item) return item.alt;
@@ -339,6 +410,7 @@ export function itemName(item: EditableSiteItem) {
export function itemMeta(item: EditableSiteItem): string {
if ("kicker" in item) return item.kicker || "轮播";
if ("destinationLabel" in item) return `${item.destinationLabel} / ${item.phoneLabel}`;
if ("spots" in item || "keyword" in item) {
const spots = "spots" in item ? item.spots : "";
const keyword = "keyword" in item ? item.keyword : "";
@@ -351,7 +423,7 @@ export function itemMeta(item: EditableSiteItem): string {
const status = "status" in item && typeof item.status === "string" ? item.status : "";
return typeof item.priceAmount === "number" ? `¥${item.priceAmount}${priceUnit}` : description || status;
}
if ("productIds" in item) return `${item.productIds.length} 条线路 / ${item.subtitle || "精选线路子分组"}`;
if ("productIds" in item) return `${item.productIds.length} 条线路 / ${item.subtitle || "推荐线路"}`;
if ("description" in item) {
const description = typeof item.description === "string" ? item.description : "";
const status = "status" in item && typeof item.status === "string" ? item.status : "";
@@ -382,5 +454,5 @@ export function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableS
}
export function moduleEditable(moduleId: ModuleId): moduleId is SiteModule {
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "destinationHero" || moduleId === "destinationRegions" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "hotelGroups" || moduleId === "vehicleOptions" || moduleId === "ctaBanners";
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "destinationHero" || moduleId === "destinationRegions" || moduleId === "demandHero" || moduleId === "demandFeatureCards" || moduleId === "demandForm" || moduleId === "demandRecommendations" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "hotelGroups" || moduleId === "vehicleOptions" || moduleId === "ctaBanners";
}

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCcw, RotateCcw } from "lucide-react";
import { getLeads } from "@/api";
import { getLeads, updateLeadStatus } from "@/api";
import type { Lead } from "@/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/select";
import { formatDate, leadStatusLabels } from "@/lib/admin-utils";
import type { Notify } from "@/types/admin";
@@ -23,19 +24,30 @@ import {
type LeadStatusFilter,
} from "./lead-helpers";
export function LeadsPage(_props: { notify: Notify }) {
export function LeadsPage({ notify }: { notify: Notify }) {
const [leads, setLeads] = useState<Lead[]>([]);
const [statusFilter, setStatusFilter] = useState<LeadStatusFilter>("all");
const [sourceFilter, setSourceFilter] = useState<LeadSourceFilter>("all");
const [keyword, setKeyword] = useState("");
const [createdFrom, setCreatedFrom] = useState("");
const [createdTo, setCreatedTo] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [updatingId, setUpdatingId] = useState("");
const load = useCallback(
async (options: { preserveMessage?: boolean } = {}) => {
setLoading(true);
if (!options.preserveMessage) setMessage("");
try {
const result = await getLeads({ status: statusFilter === "all" ? undefined : statusFilter, take: LEAD_TAKE_LIMIT });
const result = await getLeads({
status: statusFilter === "all" ? undefined : statusFilter,
sourcePage: sourceFilter === "all" || sourceFilter === "other" ? undefined : sourceFilter,
keyword: keyword.trim() || undefined,
createdFrom: createdFrom || undefined,
createdTo: createdTo || undefined,
take: LEAD_TAKE_LIMIT,
});
setLeads(result.items);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "线索加载失败";
@@ -44,7 +56,7 @@ export function LeadsPage(_props: { notify: Notify }) {
setLoading(false);
}
},
[statusFilter],
[createdFrom, createdTo, keyword, sourceFilter, statusFilter],
);
useEffect(() => {
@@ -56,8 +68,31 @@ export function LeadsPage(_props: { notify: Notify }) {
const resetFilters = () => {
setStatusFilter("all");
setSourceFilter("all");
setKeyword("");
setCreatedFrom("");
setCreatedTo("");
setMessage("");
if (statusFilter === "all") void load();
};
const updateStatus = async (lead: Lead, status: Lead["status"]) => {
if (lead.status === status || updatingId) return;
setUpdatingId(lead.id);
setMessage("");
try {
await updateLeadStatus(lead.id, status);
notify({
tone: "success",
title: "线索状态已更新",
message: `${lead.phone} 已标记为「${leadStatusLabels[status]}」。`,
});
await load({ preserveMessage: true });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "状态更新失败";
setMessage(errorMessage);
notify({ tone: "danger", title: "线索状态更新失败", message: errorMessage });
} finally {
setUpdatingId("");
}
};
const emptyMessage = loading
@@ -104,6 +139,33 @@ export function LeadsPage(_props: { notify: Notify }) {
))}
</NativeSelect>
</label>
<label className="lead-filter-field">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="联系方式 / 目的地 / 备注 / 商品"
disabled={loading}
/>
</label>
<label className="lead-filter-field">
<Input
type="date"
value={createdFrom}
onChange={(event) => setCreatedFrom(event.target.value)}
disabled={loading}
/>
</label>
<label className="lead-filter-field">
<Input
type="date"
value={createdTo}
onChange={(event) => setCreatedTo(event.target.value)}
disabled={loading}
/>
</label>
<Button variant="outline" disabled={loading}>
<RefreshCcw size={16} />
{loading ? "查询中" : "查询"}
@@ -150,6 +212,20 @@ export function LeadsPage(_props: { notify: Notify }) {
<Badge className="lead-status-chip" variant={statusVariant(lead.status)}>
{leadStatusLabels[lead.status]}
</Badge>
<NativeSelect
value={lead.status}
onChange={(event) => void updateStatus(lead, event.target.value as Lead["status"])}
disabled={loading || updatingId === lead.id}
aria-label={`更新 ${lead.phone} 的线索状态`}
>
{statusFilters
.filter((item) => item.value !== "all")
.map((item) => (
<option value={item.value} key={item.value}>
{item.label}
</option>
))}
</NativeSelect>
</span>
</article>
))}

View File

@@ -26,6 +26,7 @@ import type { Product, SiteConfig, SiteItemPatch, SiteModule } from "@/api";
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
import { EmptyState } from "@/components/admin/EmptyState";
import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSectionsPanel";
import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -210,15 +211,39 @@ const pageSpecs: {
},
{
id: "demand",
title: "需求线索",
subtitle: "维护用户提交出行需求后的分配和跟进。",
frontPath: "前台:提交出行需求 / 立即咨询",
title: "需求线索",
subtitle: "按需求页模块维护表单文案、服务说明、推荐线路和线索查询。",
frontPath: "前台:bottom tab「需求」/ 提交出行需求",
modules: [
{
id: "demandHero",
label: "顶部定制说明",
hint: "对应需求页顶部 3 步定制说明和主标题文案。",
frontPosition: "需求页顶部定制说明",
},
{
id: "demandFeatureCards",
label: "服务说明卡",
hint: "对应需求页三张服务说明卡片,可维护标题、描述、启停和顺序。",
frontPosition: "需求页服务说明卡",
},
{
id: "demandForm",
label: "需求表单配置",
hint: "维护固定表单字段标签、占位符、提交按钮和快捷选项。",
frontPosition: "需求页表单",
},
{
id: "demandRecommendations",
label: "热门推荐线路",
hint: "维护需求页热门推荐标题、副文案和关联商品顺序。",
frontPosition: "需求页热门推荐线路",
},
{
id: "leadFlow",
label: "线索跟进",
label: "线索查询跟进",
hint: "查看联系方式、目的地、来源商品并推进状态。",
frontPosition: "需求表单提交后",
frontPosition: "需求表单查询",
},
],
},
@@ -259,10 +284,11 @@ export function StructurePage({
const isMapModule = moduleId === "map";
const isCampaignModule = moduleId === "campaigns";
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
const isDemandFormModule = moduleId === "demandForm";
const canReorderSiteItems =
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule;
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule && !isDemandFormModule;
const canCreateSiteItem =
moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0);
moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0) && (!isDemandFormModule || editableItems.length === 0);
const showRouteSectionCreateButton = isRouteSectionsModule;
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
const selectedItem = isCreatingSiteItem
@@ -300,7 +326,9 @@ export function StructurePage({
setDraft({
title: "title" in item ? item.title : undefined,
subtitle: "subtitle" in item ? item.subtitle || "" : undefined,
kicker: "kicker" in item ? item.kicker || "" : undefined,
steps: "steps" in item && Array.isArray(item.steps) ? item.steps : undefined,
name: "name" in item ? item.name : undefined,
slug: "slug" in item ? item.slug : undefined,
region: "region" in item ? item.region || "" : undefined,
@@ -314,6 +342,15 @@ export function StructurePage({
priceAmount: "priceAmount" in item ? item.priceAmount : undefined,
priceUnit: "priceUnit" in item ? item.priceUnit || (moduleId === "hotelGroups" ? "起/晚" : "起/人") : moduleId === "hotelGroups" ? "起/晚" : undefined,
tags: "tags" in item && Array.isArray(item.tags) ? item.tags.slice(0, 3) : moduleId === "hotelGroups" ? [] : undefined,
destinationLabel: "destinationLabel" in item ? item.destinationLabel : undefined,
destinationPlaceholder: "destinationPlaceholder" in item ? item.destinationPlaceholder || "" : undefined,
phoneLabel: "phoneLabel" in item ? item.phoneLabel : undefined,
phonePlaceholder: "phonePlaceholder" in item ? item.phonePlaceholder || "" : undefined,
noteLabel: "noteLabel" in item ? item.noteLabel : undefined,
notePlaceholder: "notePlaceholder" in item ? item.notePlaceholder || "" : undefined,
submitLabel: "submitLabel" in item ? item.submitLabel : undefined,
chips: "chips" in item && Array.isArray(item.chips) ? item.chips : undefined,
productIds: "productIds" in item && Array.isArray(item.productIds) ? item.productIds : undefined,
isHot: "isHot" in item ? item.isHot : undefined,
isActive: "isActive" in item ? item.isActive : moduleId === "hotelGroups" ? activeStatus === "published" : undefined,
sortOrder: itemSortOrder(item),
@@ -681,7 +718,7 @@ export function StructurePage({
onClick={startCreateSiteItem}
>
<Plus size={16} />
{isMapModule ? "上传地图" : "新增"}
{isMapModule ? "上传地图" : "新增"}
</Button>
) : showRouteSectionCreateButton ? (
<Button
@@ -816,6 +853,7 @@ export function StructurePage({
}}
onSave={save}
onClose={closeEditorDrawer}
products={products}
/>
</section>
</div>
@@ -902,7 +940,7 @@ function ReferencePanel({
</p>
<Button
className="primary-action compact"
onClick={() => onJump("leads")}
onClick={() => onJump("leadList")}
>
线
</Button>
@@ -942,6 +980,7 @@ function SiteItemEditor({
onDraft,
onSave,
onClose,
products,
}: {
moduleId: SiteModule;
moduleLabel: string;
@@ -951,6 +990,7 @@ function SiteItemEditor({
onDraft: (draft: SiteItemPatch) => void;
onSave: () => void;
onClose: () => void;
products: Product[];
}) {
const primaryKey = siteItemPrimaryKey(moduleId);
const primaryLabel = (() => {
@@ -958,6 +998,10 @@ function SiteItemEditor({
if (moduleId === "destinations") return "目的地名称";
if (moduleId === "destinationHero") return "主视觉标题";
if (moduleId === "destinationRegions") return "区域名称";
if (moduleId === "demandHero") return "主标题";
if (moduleId === "demandFeatureCards") return "卡片标题";
if (moduleId === "demandForm") return "提交按钮文案";
if (moduleId === "demandRecommendations") return "推荐标题";
if (moduleId === "map") return "地图图片";
if (moduleId === "themes") return "主题名称";
if (moduleId === "campaigns") return "活动标题";
@@ -972,10 +1016,23 @@ function SiteItemEditor({
const isHotelModule = moduleId === "hotelGroups";
const isDestinationHeroModule = moduleId === "destinationHero";
const isDestinationRegionModule = moduleId === "destinationRegions";
const isTextOnlyModule = isDestinationRegionModule;
const isDemandHeroModule = moduleId === "demandHero";
const isDemandFeatureCardModule = moduleId === "demandFeatureCards";
const isDemandFormModule = moduleId === "demandForm";
const isDemandRecommendationModule = moduleId === "demandRecommendations";
const isDemandTextModule =
isDemandHeroModule ||
isDemandFeatureCardModule ||
isDemandFormModule ||
isDemandRecommendationModule;
const isTextOnlyModule = isDestinationRegionModule || isDemandTextModule;
const usesOfferConfig = isCampaignModule || isHotelModule;
const isMoreServicesModule = moduleId === "ctaBanners";
const isCardContentModule = moduleId === "vehicleOptions";
const demandProductIds = draft.productIds ?? [];
const updateDemandProductIds = (nextIds: string[]) => {
onDraft({ ...draft, productIds: Array.from(new Set(nextIds.filter(Boolean))) });
};
const editorHint = isImageOnlyModule
? isCreating
? `上传后会作为「${moduleLabel}」唯一展示图片`
@@ -1037,6 +1094,91 @@ function SiteItemEditor({
/>
</label>
</>
) : isDemandFormModule ? (
<>
<div className="form-grid compact-grid">
<label>
<Input
value={draft.destinationLabel ?? ""}
onChange={(event) =>
onDraft({ ...draft, destinationLabel: event.target.value })
}
/>
</label>
<label>
<Input
value={draft.destinationPlaceholder ?? ""}
onChange={(event) =>
onDraft({ ...draft, destinationPlaceholder: event.target.value })
}
/>
</label>
<label>
<Input
value={draft.phoneLabel ?? ""}
onChange={(event) =>
onDraft({ ...draft, phoneLabel: event.target.value })
}
/>
</label>
<label>
<Input
value={draft.phonePlaceholder ?? ""}
onChange={(event) =>
onDraft({ ...draft, phonePlaceholder: event.target.value })
}
/>
</label>
</div>
<label>
<Input
value={draft.noteLabel ?? ""}
onChange={(event) =>
onDraft({ ...draft, noteLabel: event.target.value })
}
/>
</label>
<label>
<Textarea
value={draft.notePlaceholder ?? ""}
onChange={(event) =>
onDraft({ ...draft, notePlaceholder: event.target.value })
}
rows={3}
/>
</label>
<label>
<Input
value={draft.submitLabel ?? ""}
onChange={(event) =>
onDraft({ ...draft, submitLabel: event.target.value })
}
/>
</label>
<label>
<Textarea
value={(draft.chips ?? []).join("\n")}
onChange={(event) =>
onDraft({
...draft,
chips: event.target.value
.split(/\r?\n|,|/)
.map((item) => item.trim())
.filter(Boolean),
})
}
rows={5}
/>
</label>
</>
) : (
<>
<label>
@@ -1059,6 +1201,69 @@ function SiteItemEditor({
/>
</label>
) : null}
{isDemandHeroModule ? (
<>
<label>
<Input
value={draft.kicker ?? ""}
onChange={(event) =>
onDraft({ ...draft, kicker: event.target.value })
}
/>
</label>
<label>
<Textarea
value={draft.description ?? ""}
onChange={(event) =>
onDraft({ ...draft, description: event.target.value })
}
rows={3}
/>
</label>
<label>
<Textarea
value={(draft.steps ?? []).join("\n")}
onChange={(event) =>
onDraft({
...draft,
steps: event.target.value
.split(/\r?\n|,|/)
.map((item) => item.trim())
.filter(Boolean),
})
}
rows={4}
/>
</label>
</>
) : null}
{isDemandFeatureCardModule ? (
<label>
<Textarea
value={draft.description ?? ""}
onChange={(event) =>
onDraft({ ...draft, description: event.target.value })
}
rows={3}
/>
</label>
) : null}
{isDemandRecommendationModule ? (
<label>
<Textarea
value={draft.subtitle ?? ""}
onChange={(event) =>
onDraft({ ...draft, subtitle: event.target.value })
}
rows={2}
/>
</label>
) : null}
{isDestinationRegionModule ? (
<>
<label>
@@ -1099,6 +1304,25 @@ function SiteItemEditor({
)}
</AdminDisclosure>
) : null}
{isDemandRecommendationModule ? (
<AdminDisclosure title="关联推荐线路">
<RouteSectionProductPicker
products={products}
productIds={demandProductIds}
productUsage={new Map()}
onAdd={(productId) => updateDemandProductIds([...demandProductIds, productId])}
onRemove={(productId) => updateDemandProductIds(demandProductIds.filter((id) => id !== productId))}
onMove={(productId, direction) => {
const index = demandProductIds.indexOf(productId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= demandProductIds.length) return;
const nextIds = [...demandProductIds];
[nextIds[index], nextIds[nextIndex]] = [nextIds[nextIndex], nextIds[index]];
updateDemandProductIds(nextIds);
}}
/>
</AdminDisclosure>
) : null}
{isMoreServicesModule ? (
<AdminDisclosure title="跳转配置">
<div className="form-grid compact-grid">

View File

@@ -1,6 +1,6 @@
import type { SiteConfig, SiteModule } from "@/api";
export type Tab = "structure" | "home" | "destinations" | "products" | "leads";
export type Tab = "structure" | "home" | "destinations" | "products" | "leads" | "leadList";
export type PageId = "home" | "destination" | "detail" | "campaign" | "demand";
export type ModuleId = SiteModule | "routeProducts" | "campaignProducts" | "leadFlow";
@@ -19,6 +19,10 @@ export type EditableSiteItem =
| SiteConfig["destinations"][number]
| SiteConfig["destinationHero"][number]
| SiteConfig["destinationRegions"][number]
| SiteConfig["demandHero"][number]
| SiteConfig["demandFeatureCards"][number]
| SiteConfig["demandForm"][number]
| SiteConfig["demandRecommendations"][number]
| SiteConfig["map"][number]
| SiteConfig["themes"][number]
| SiteConfig["campaigns"][number]