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;
|
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 = {
|
export type Product = {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId?: number | null;
|
sourceId?: number | null;
|
||||||
@@ -95,6 +143,10 @@ export type Lead = {
|
|||||||
|
|
||||||
export type LeadListParams = {
|
export type LeadListParams = {
|
||||||
status?: LeadStatus;
|
status?: LeadStatus;
|
||||||
|
sourcePage?: string;
|
||||||
|
keyword?: string;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
take?: number;
|
take?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +220,10 @@ export type SiteConfig = {
|
|||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
destinationHero: DestinationHero[];
|
destinationHero: DestinationHero[];
|
||||||
destinationRegions: DestinationRegion[];
|
destinationRegions: DestinationRegion[];
|
||||||
|
demandHero: DemandHero[];
|
||||||
|
demandFeatureCards: DemandFeatureCard[];
|
||||||
|
demandForm: DemandForm[];
|
||||||
|
demandRecommendations: DemandRecommendation[];
|
||||||
map: Array<{ id: string; image: string | null; isActive: boolean; createdAt?: string; updatedAt?: string }>;
|
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 }>;
|
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
|
||||||
campaigns: Campaign[];
|
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 }>;
|
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];
|
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
||||||
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
||||||
|
|
||||||
@@ -198,6 +269,15 @@ export type SiteItemPatch = {
|
|||||||
priceAmount?: number | null;
|
priceAmount?: number | null;
|
||||||
priceUnit?: string | null;
|
priceUnit?: string | null;
|
||||||
tags?: string[];
|
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;
|
targetType?: string | null;
|
||||||
targetValue?: string | null;
|
targetValue?: string | null;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
@@ -354,6 +434,10 @@ export async function reorderSiteConfigItems(module: SiteModule, itemIds: string
|
|||||||
export async function getLeads(params: LeadListParams = {}) {
|
export async function getLeads(params: LeadListParams = {}) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params.status) search.set("status", params.status);
|
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));
|
if (params.take) search.set("take", String(params.take));
|
||||||
const query = search.toString();
|
const query = search.toString();
|
||||||
return request<{ items: Lead[] }>(`/api/admin/leads${query ? `?${query}` : ""}`);
|
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 tabs = sidebarGroups.flatMap((group) => group.items);
|
||||||
|
const hiddenTabs: Partial<Record<Tab, { label: string; description: string }>> = {
|
||||||
|
leadList: { label: "线索查询跟进", description: "表单查询和状态流转" },
|
||||||
|
};
|
||||||
|
|
||||||
export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("structure");
|
const [activeTab, setActiveTab] = useState<Tab>("structure");
|
||||||
@@ -50,7 +53,7 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
|||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
}, [activeTab]);
|
}, [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 activeLabel = activeMeta?.label ?? "";
|
||||||
const activeDescription = activeMeta?.description ?? "";
|
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} />
|
<StructurePage key="destination-workbench" fixedPage="destination" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||||
) : activeTab === "products" ? (
|
) : activeTab === "products" ? (
|
||||||
<ProductsPage destinations={destinations} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
<ProductsPage destinations={destinations} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||||
|
) : activeTab === "leads" ? (
|
||||||
|
<StructurePage key="demand-workbench" fixedPage="demand" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||||
) : (
|
) : (
|
||||||
<LeadsPage notify={notify} />
|
<LeadsPage notify={notify} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
|||||||
if (moduleId === "destinations") return "name";
|
if (moduleId === "destinations") return "name";
|
||||||
if (moduleId === "destinationHero") return "title";
|
if (moduleId === "destinationHero") return "title";
|
||||||
if (moduleId === "destinationRegions") return "label";
|
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 === "map") return "image";
|
||||||
if (moduleId === "themes") return "label";
|
if (moduleId === "themes") return "label";
|
||||||
if (moduleId === "campaigns") return "title";
|
if (moduleId === "campaigns") return "title";
|
||||||
@@ -165,6 +167,28 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
|
|||||||
if (moduleId === "destinationRegions") {
|
if (moduleId === "destinationRegions") {
|
||||||
return { label: "", keyword: "", spots: "", isActive: true, sortOrder };
|
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") {
|
if (moduleId === "map") {
|
||||||
return { image: "", isActive: false };
|
return { image: "", isActive: false };
|
||||||
}
|
}
|
||||||
@@ -246,6 +270,52 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
|
|||||||
payload.targetType = undefined;
|
payload.targetType = undefined;
|
||||||
payload.targetValue = 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") {
|
if (moduleId === "map") {
|
||||||
payload.sortOrder = undefined;
|
payload.sortOrder = undefined;
|
||||||
}
|
}
|
||||||
@@ -331,6 +401,7 @@ export function readImageAsDataUrl(file: File) {
|
|||||||
|
|
||||||
export function itemName(item: EditableSiteItem) {
|
export function itemName(item: EditableSiteItem) {
|
||||||
if ("title" in item) return item.title;
|
if ("title" in item) return item.title;
|
||||||
|
if ("submitLabel" in item) return "需求表单配置";
|
||||||
if ("name" in item) return item.name;
|
if ("name" in item) return item.name;
|
||||||
if ("label" in item) return item.label;
|
if ("label" in item) return item.label;
|
||||||
if ("alt" in item) return item.alt;
|
if ("alt" in item) return item.alt;
|
||||||
@@ -339,6 +410,7 @@ export function itemName(item: EditableSiteItem) {
|
|||||||
|
|
||||||
export function itemMeta(item: EditableSiteItem): string {
|
export function itemMeta(item: EditableSiteItem): string {
|
||||||
if ("kicker" in item) return item.kicker || "轮播";
|
if ("kicker" in item) return item.kicker || "轮播";
|
||||||
|
if ("destinationLabel" in item) return `${item.destinationLabel} / ${item.phoneLabel}`;
|
||||||
if ("spots" in item || "keyword" in item) {
|
if ("spots" in item || "keyword" in item) {
|
||||||
const spots = "spots" in item ? item.spots : "";
|
const spots = "spots" in item ? item.spots : "";
|
||||||
const keyword = "keyword" in item ? item.keyword : "";
|
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 : "";
|
const status = "status" in item && typeof item.status === "string" ? item.status : "";
|
||||||
return typeof item.priceAmount === "number" ? `¥${item.priceAmount}${priceUnit}` : description || 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) {
|
if ("description" in item) {
|
||||||
const description = typeof item.description === "string" ? item.description : "";
|
const description = typeof item.description === "string" ? item.description : "";
|
||||||
const status = "status" in item && typeof item.status === "string" ? item.status : "";
|
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 {
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshCcw, RotateCcw } from "lucide-react";
|
import { RefreshCcw, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
import { getLeads } from "@/api";
|
import { getLeads, updateLeadStatus } from "@/api";
|
||||||
import type { Lead } from "@/api";
|
import type { Lead } from "@/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { NativeSelect } from "@/components/ui/select";
|
import { NativeSelect } from "@/components/ui/select";
|
||||||
import { formatDate, leadStatusLabels } from "@/lib/admin-utils";
|
import { formatDate, leadStatusLabels } from "@/lib/admin-utils";
|
||||||
import type { Notify } from "@/types/admin";
|
import type { Notify } from "@/types/admin";
|
||||||
@@ -23,19 +24,30 @@ import {
|
|||||||
type LeadStatusFilter,
|
type LeadStatusFilter,
|
||||||
} from "./lead-helpers";
|
} from "./lead-helpers";
|
||||||
|
|
||||||
export function LeadsPage(_props: { notify: Notify }) {
|
export function LeadsPage({ notify }: { notify: Notify }) {
|
||||||
const [leads, setLeads] = useState<Lead[]>([]);
|
const [leads, setLeads] = useState<Lead[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<LeadStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<LeadStatusFilter>("all");
|
||||||
const [sourceFilter, setSourceFilter] = useState<LeadSourceFilter>("all");
|
const [sourceFilter, setSourceFilter] = useState<LeadSourceFilter>("all");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [createdFrom, setCreatedFrom] = useState("");
|
||||||
|
const [createdTo, setCreatedTo] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updatingId, setUpdatingId] = useState("");
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (options: { preserveMessage?: boolean } = {}) => {
|
async (options: { preserveMessage?: boolean } = {}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (!options.preserveMessage) setMessage("");
|
if (!options.preserveMessage) setMessage("");
|
||||||
try {
|
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);
|
setLeads(result.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "线索加载失败";
|
const errorMessage = err instanceof Error ? err.message : "线索加载失败";
|
||||||
@@ -44,7 +56,7 @@ export function LeadsPage(_props: { notify: Notify }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[statusFilter],
|
[createdFrom, createdTo, keyword, sourceFilter, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,8 +68,31 @@ export function LeadsPage(_props: { notify: Notify }) {
|
|||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setStatusFilter("all");
|
setStatusFilter("all");
|
||||||
setSourceFilter("all");
|
setSourceFilter("all");
|
||||||
|
setKeyword("");
|
||||||
|
setCreatedFrom("");
|
||||||
|
setCreatedTo("");
|
||||||
setMessage("");
|
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
|
const emptyMessage = loading
|
||||||
@@ -104,6 +139,33 @@ export function LeadsPage(_props: { notify: Notify }) {
|
|||||||
))}
|
))}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</label>
|
</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}>
|
<Button variant="outline" disabled={loading}>
|
||||||
<RefreshCcw size={16} />
|
<RefreshCcw size={16} />
|
||||||
{loading ? "查询中" : "查询"}
|
{loading ? "查询中" : "查询"}
|
||||||
@@ -150,6 +212,20 @@ export function LeadsPage(_props: { notify: Notify }) {
|
|||||||
<Badge className="lead-status-chip" variant={statusVariant(lead.status)}>
|
<Badge className="lead-status-chip" variant={statusVariant(lead.status)}>
|
||||||
{leadStatusLabels[lead.status]}
|
{leadStatusLabels[lead.status]}
|
||||||
</Badge>
|
</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>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { Product, SiteConfig, SiteItemPatch, SiteModule } from "@/api";
|
|||||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||||
import { EmptyState } from "@/components/admin/EmptyState";
|
import { EmptyState } from "@/components/admin/EmptyState";
|
||||||
import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSectionsPanel";
|
import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSectionsPanel";
|
||||||
|
import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker";
|
||||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -210,15 +211,39 @@ const pageSpecs: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "demand",
|
id: "demand",
|
||||||
title: "需求线索",
|
title: "需求线索",
|
||||||
subtitle: "维护用户提交出行需求后的分配和跟进。",
|
subtitle: "按需求页模块维护表单文案、服务说明、推荐线路和线索查询。",
|
||||||
frontPath: "前台:提交出行需求 / 立即咨询",
|
frontPath: "前台:bottom tab「需求」/ 提交出行需求",
|
||||||
modules: [
|
modules: [
|
||||||
|
{
|
||||||
|
id: "demandHero",
|
||||||
|
label: "顶部定制说明",
|
||||||
|
hint: "对应需求页顶部 3 步定制说明和主标题文案。",
|
||||||
|
frontPosition: "需求页顶部定制说明",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demandFeatureCards",
|
||||||
|
label: "服务说明卡",
|
||||||
|
hint: "对应需求页三张服务说明卡片,可维护标题、描述、启停和顺序。",
|
||||||
|
frontPosition: "需求页服务说明卡",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demandForm",
|
||||||
|
label: "需求表单配置",
|
||||||
|
hint: "维护固定表单字段标签、占位符、提交按钮和快捷选项。",
|
||||||
|
frontPosition: "需求页表单",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demandRecommendations",
|
||||||
|
label: "热门推荐线路",
|
||||||
|
hint: "维护需求页热门推荐标题、副文案和关联商品顺序。",
|
||||||
|
frontPosition: "需求页热门推荐线路",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "leadFlow",
|
id: "leadFlow",
|
||||||
label: "线索跟进",
|
label: "线索查询跟进",
|
||||||
hint: "查看联系方式、目的地、来源商品并推进状态。",
|
hint: "查看联系方式、目的地、来源商品并推进状态。",
|
||||||
frontPosition: "需求表单提交后",
|
frontPosition: "需求页表单查询",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -259,10 +284,11 @@ export function StructurePage({
|
|||||||
const isMapModule = moduleId === "map";
|
const isMapModule = moduleId === "map";
|
||||||
const isCampaignModule = moduleId === "campaigns";
|
const isCampaignModule = moduleId === "campaigns";
|
||||||
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
|
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
|
||||||
|
const isDemandFormModule = moduleId === "demandForm";
|
||||||
const canReorderSiteItems =
|
const canReorderSiteItems =
|
||||||
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule;
|
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule && !isDemandFormModule;
|
||||||
const canCreateSiteItem =
|
const canCreateSiteItem =
|
||||||
moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0);
|
moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0) && (!isDemandFormModule || editableItems.length === 0);
|
||||||
const showRouteSectionCreateButton = isRouteSectionsModule;
|
const showRouteSectionCreateButton = isRouteSectionsModule;
|
||||||
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
||||||
const selectedItem = isCreatingSiteItem
|
const selectedItem = isCreatingSiteItem
|
||||||
@@ -300,7 +326,9 @@ export function StructurePage({
|
|||||||
|
|
||||||
setDraft({
|
setDraft({
|
||||||
title: "title" in item ? item.title : undefined,
|
title: "title" in item ? item.title : undefined,
|
||||||
|
subtitle: "subtitle" in item ? item.subtitle || "" : undefined,
|
||||||
kicker: "kicker" in item ? item.kicker || "" : 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,
|
name: "name" in item ? item.name : undefined,
|
||||||
slug: "slug" in item ? item.slug : undefined,
|
slug: "slug" in item ? item.slug : undefined,
|
||||||
region: "region" in item ? item.region || "" : undefined,
|
region: "region" in item ? item.region || "" : undefined,
|
||||||
@@ -314,6 +342,15 @@ export function StructurePage({
|
|||||||
priceAmount: "priceAmount" in item ? item.priceAmount : undefined,
|
priceAmount: "priceAmount" in item ? item.priceAmount : undefined,
|
||||||
priceUnit: "priceUnit" in item ? item.priceUnit || (moduleId === "hotelGroups" ? "起/晚" : "起/人") : moduleId === "hotelGroups" ? "起/晚" : 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,
|
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,
|
isHot: "isHot" in item ? item.isHot : undefined,
|
||||||
isActive: "isActive" in item ? item.isActive : moduleId === "hotelGroups" ? activeStatus === "published" : undefined,
|
isActive: "isActive" in item ? item.isActive : moduleId === "hotelGroups" ? activeStatus === "published" : undefined,
|
||||||
sortOrder: itemSortOrder(item),
|
sortOrder: itemSortOrder(item),
|
||||||
@@ -681,7 +718,7 @@ export function StructurePage({
|
|||||||
onClick={startCreateSiteItem}
|
onClick={startCreateSiteItem}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{isMapModule ? "上传地图" : "新增"}
|
{isMapModule ? "上传地图" : "新增"}
|
||||||
</Button>
|
</Button>
|
||||||
) : showRouteSectionCreateButton ? (
|
) : showRouteSectionCreateButton ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -816,6 +853,7 @@ export function StructurePage({
|
|||||||
}}
|
}}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
onClose={closeEditorDrawer}
|
onClose={closeEditorDrawer}
|
||||||
|
products={products}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -902,7 +940,7 @@ function ReferencePanel({
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className="primary-action compact"
|
className="primary-action compact"
|
||||||
onClick={() => onJump("leads")}
|
onClick={() => onJump("leadList")}
|
||||||
>
|
>
|
||||||
进入需求线索
|
进入需求线索
|
||||||
</Button>
|
</Button>
|
||||||
@@ -942,6 +980,7 @@ function SiteItemEditor({
|
|||||||
onDraft,
|
onDraft,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
|
products,
|
||||||
}: {
|
}: {
|
||||||
moduleId: SiteModule;
|
moduleId: SiteModule;
|
||||||
moduleLabel: string;
|
moduleLabel: string;
|
||||||
@@ -951,6 +990,7 @@ function SiteItemEditor({
|
|||||||
onDraft: (draft: SiteItemPatch) => void;
|
onDraft: (draft: SiteItemPatch) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
products: Product[];
|
||||||
}) {
|
}) {
|
||||||
const primaryKey = siteItemPrimaryKey(moduleId);
|
const primaryKey = siteItemPrimaryKey(moduleId);
|
||||||
const primaryLabel = (() => {
|
const primaryLabel = (() => {
|
||||||
@@ -958,6 +998,10 @@ function SiteItemEditor({
|
|||||||
if (moduleId === "destinations") return "目的地名称";
|
if (moduleId === "destinations") return "目的地名称";
|
||||||
if (moduleId === "destinationHero") return "主视觉标题";
|
if (moduleId === "destinationHero") return "主视觉标题";
|
||||||
if (moduleId === "destinationRegions") 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 === "map") return "地图图片";
|
||||||
if (moduleId === "themes") return "主题名称";
|
if (moduleId === "themes") return "主题名称";
|
||||||
if (moduleId === "campaigns") return "活动标题";
|
if (moduleId === "campaigns") return "活动标题";
|
||||||
@@ -972,10 +1016,23 @@ function SiteItemEditor({
|
|||||||
const isHotelModule = moduleId === "hotelGroups";
|
const isHotelModule = moduleId === "hotelGroups";
|
||||||
const isDestinationHeroModule = moduleId === "destinationHero";
|
const isDestinationHeroModule = moduleId === "destinationHero";
|
||||||
const isDestinationRegionModule = moduleId === "destinationRegions";
|
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 usesOfferConfig = isCampaignModule || isHotelModule;
|
||||||
const isMoreServicesModule = moduleId === "ctaBanners";
|
const isMoreServicesModule = moduleId === "ctaBanners";
|
||||||
const isCardContentModule = moduleId === "vehicleOptions";
|
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
|
const editorHint = isImageOnlyModule
|
||||||
? isCreating
|
? isCreating
|
||||||
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
||||||
@@ -1037,6 +1094,91 @@ function SiteItemEditor({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
@@ -1059,6 +1201,69 @@ function SiteItemEditor({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : 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 ? (
|
{isDestinationRegionModule ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -1099,6 +1304,25 @@ function SiteItemEditor({
|
|||||||
)}
|
)}
|
||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
) : null}
|
) : 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 ? (
|
{isMoreServicesModule ? (
|
||||||
<AdminDisclosure title="跳转配置">
|
<AdminDisclosure title="跳转配置">
|
||||||
<div className="form-grid compact-grid">
|
<div className="form-grid compact-grid">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SiteConfig, SiteModule } from "@/api";
|
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 PageId = "home" | "destination" | "detail" | "campaign" | "demand";
|
||||||
export type ModuleId = SiteModule | "routeProducts" | "campaignProducts" | "leadFlow";
|
export type ModuleId = SiteModule | "routeProducts" | "campaignProducts" | "leadFlow";
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ export type EditableSiteItem =
|
|||||||
| SiteConfig["destinations"][number]
|
| SiteConfig["destinations"][number]
|
||||||
| SiteConfig["destinationHero"][number]
|
| SiteConfig["destinationHero"][number]
|
||||||
| SiteConfig["destinationRegions"][number]
|
| SiteConfig["destinationRegions"][number]
|
||||||
|
| SiteConfig["demandHero"][number]
|
||||||
|
| SiteConfig["demandFeatureCards"][number]
|
||||||
|
| SiteConfig["demandForm"][number]
|
||||||
|
| SiteConfig["demandRecommendations"][number]
|
||||||
| SiteConfig["map"][number]
|
| SiteConfig["map"][number]
|
||||||
| SiteConfig["themes"][number]
|
| SiteConfig["themes"][number]
|
||||||
| SiteConfig["campaigns"][number]
|
| SiteConfig["campaigns"][number]
|
||||||
|
|||||||
Reference in New Issue
Block a user