diff --git a/src/api.ts b/src/api.ts index e34dd97..566719e 100644 --- a/src/api.ts +++ b/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}` : ""}`); diff --git a/src/components/admin/AdminShell.tsx b/src/components/admin/AdminShell.tsx index 39f615e..4f0f264 100644 --- a/src/components/admin/AdminShell.tsx +++ b/src/components/admin/AdminShell.tsx @@ -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> = { + leadList: { label: "线索查询跟进", description: "表单查询和状态流转" }, +}; export function AdminShell({ onLogout }: { onLogout: () => void }) { const [activeTab, setActiveTab] = useState("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 }) { ) : activeTab === "products" ? ( + ) : activeTab === "leads" ? ( + ) : ( )} diff --git a/src/lib/admin-utils.ts b/src/lib/admin-utils.ts index ecbb315..79519e3 100644 --- a/src/lib/admin-utils.ts +++ b/src/lib/admin-utils.ts @@ -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"; } diff --git a/src/pages/leads/LeadsPage.tsx b/src/pages/leads/LeadsPage.tsx index 4c23cfa..27bac7e 100644 --- a/src/pages/leads/LeadsPage.tsx +++ b/src/pages/leads/LeadsPage.tsx @@ -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([]); const [statusFilter, setStatusFilter] = useState("all"); const [sourceFilter, setSourceFilter] = useState("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 }) { ))} + + + ) : showRouteSectionCreateButton ? ( @@ -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({ /> + ) : isDemandFormModule ? ( + <> +
+ + + + +
+ +