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:
86
src/api.ts
86
src/api.ts
@@ -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}` : ""}`);
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user