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 ? (
@@ -902,7 +940,7 @@ function ReferencePanel({
@@ -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 ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
) : (
<>
) : null}
+ {isDemandHeroModule ? (
+ <>
+
+
+
+ >
+ ) : null}
+ {isDemandFeatureCardModule ? (
+
+ ) : null}
+ {isDemandRecommendationModule ? (
+
+ ) : null}
{isDestinationRegionModule ? (
<>