diff --git a/WonderQ-Admin-UI/src/api.ts b/WonderQ-Admin-UI/src/api.ts index 93273e8..8b97b50 100644 --- a/WonderQ-Admin-UI/src/api.ts +++ b/WonderQ-Admin-UI/src/api.ts @@ -70,6 +70,28 @@ export type VehicleOption = { updatedAt?: string; }; +export type VehicleServiceSection = { + title: string; + description: string; +}; + +export type VehicleServiceStep = { + title: string; + description: string; +}; + +export type VehicleServiceConfig = { + id: string; + introTitle: string; + intro: string; + serviceSections: VehicleServiceSection[]; + advantages: string[]; + processSteps: VehicleServiceStep[]; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + export type SiteConfig = { heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>; destinationHero: DestinationHero[]; @@ -77,6 +99,7 @@ export type SiteConfig = { demandFeatureCards: DemandFeatureCard[]; demandForm: DemandForm[]; vehicleOptions: VehicleOption[]; + vehicleService: VehicleServiceConfig[]; }; export type SiteModule = @@ -85,7 +108,8 @@ export type SiteModule = | "demandHero" | "demandFeatureCards" | "demandForm" - | "vehicleOptions"; + | "vehicleOptions" + | "vehicleService"; export type WanfaRoute = { id: string; @@ -275,6 +299,11 @@ export type SiteItemPatch = { notePlaceholder?: string | null; submitLabel?: string; chips?: string[]; + introTitle?: string; + intro?: string; + serviceSections?: VehicleServiceSection[]; + advantages?: string[]; + processSteps?: VehicleServiceStep[]; targetType?: string | null; targetValue?: string | null; isActive?: boolean; @@ -430,6 +459,60 @@ export async function getSiteConfig() { return request("/api/admin/site-config"); } +export type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "invalid"; +export type LeadType = "general" | "vehicle"; + +export type VehicleDemand = { + serviceType: "charter" | "transfer"; + charterDuration?: "halfDay" | "fullDay" | null; + travelDate: string; + pickupTime?: string | null; + pickupLocation: string; + dropoffLocation: string; + peopleCount: number; + luggageCount?: number | null; + vehicleOptionId?: string | null; + vehicleOptionTitle?: string | null; + specialRequirements?: string | null; +}; + +export type Lead = { + id: string; + leadType: LeadType; + contactName?: string | null; + customerId?: string | null; + destination?: string | null; + phone: string; + travelDate?: string | null; + peopleCount?: number | null; + budgetMin?: number | null; + budgetMax?: number | null; + note?: string | null; + sourcePage?: string | null; + vehicleDemand?: VehicleDemand | null; + status: LeadStatus; + assignedUser?: { id: string; name: string } | null; + createdAt?: string; + updatedAt?: string; +}; + +export async function getLeads(params: { leadType?: LeadType; status?: LeadStatus; keyword?: string; take?: number } = {}) { + const query = new URLSearchParams(); + if (params.leadType) query.set("leadType", params.leadType); + if (params.status) query.set("status", params.status); + if (params.keyword?.trim()) query.set("keyword", params.keyword.trim()); + if (params.take) query.set("take", String(params.take)); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return request<{ items: Lead[] }>(`/api/admin/leads${suffix}`); +} + +export async function updateLeadStatus(leadId: string, status: LeadStatus) { + return request(`/api/admin/leads/${leadId}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); +} + export async function getWanfaCategories() { return request<{ categories: WanfaCategory[] }>("/api/admin/wanfa/categories"); } diff --git a/WonderQ-Admin-UI/src/components/admin/AdminShell.tsx b/WonderQ-Admin-UI/src/components/admin/AdminShell.tsx index d93e1bd..7511025 100644 --- a/WonderQ-Admin-UI/src/components/admin/AdminShell.tsx +++ b/WonderQ-Admin-UI/src/components/admin/AdminShell.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Headset, Home, List, LogOut } from "lucide-react"; +import { ClipboardList, Headset, Home, List, LogOut } from "lucide-react"; import { clearToken } from "@/api"; import { ToastStack } from "@/components/admin/ToastStack"; @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { ConciergePage } from "@/pages/structure/ConciergePage"; import { HomePage } from "@/pages/structure/HomePage"; import { WanfaPage } from "@/pages/structure/WanfaPage"; +import { LeadsPage } from "@/pages/leads/LeadsPage"; import type { Tab, Toast } from "@/types/admin"; const sidebarGroups: { @@ -21,6 +22,7 @@ const sidebarGroups: { { id: "home", label: "首页", icon: Home, description: "" }, { id: "play", label: "玩法", icon: List, description: "" }, { id: "butler", label: "管家", icon: Headset, description: "" }, + { id: "leads", label: "需求线索", icon: ClipboardList, description: "" }, ], }, ]; @@ -118,6 +120,8 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) { ) : activeTab === "butler" ? ( + ) : activeTab === "leads" ? ( + ) : null} ({ title: item.title.trim(), description: item.description.trim() })) + .filter((item) => item.title || item.description); + payload.advantages = (draft.advantages ?? []).map((item) => item.trim()).filter(Boolean); + payload.processSteps = (draft.processSteps ?? []) + .map((item) => ({ title: item.title.trim(), description: item.description.trim() })) + .filter((item) => item.title || item.description); + payload.title = undefined; + payload.image = undefined; + payload.sortOrder = undefined; + } return payload; } @@ -125,6 +150,7 @@ export function readImageAsDataUrl(file: File) { export function itemName(item: EditableSiteItem) { if ("title" in item) return item.title; if ("submitLabel" in item) return "需求表单配置"; + if ("introTitle" in item) return item.introTitle; return "配置项"; } @@ -135,6 +161,7 @@ export function itemMeta(item: EditableSiteItem): string { const description = typeof item.description === "string" ? item.description : ""; return description; } + if ("intro" in item) return item.intro || "用车需求页服务说明"; if (!("title" in item)) return "配置项"; return ""; } @@ -153,5 +180,5 @@ export function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableS } export function moduleEditable(moduleId: ModuleId): moduleId is SiteModule { - return moduleId === "heroSlides" || moduleId === "destinationHero" || moduleId === "demandHero" || moduleId === "demandFeatureCards" || moduleId === "demandForm" || moduleId === "vehicleOptions"; + return moduleId === "heroSlides" || moduleId === "destinationHero" || moduleId === "demandHero" || moduleId === "demandFeatureCards" || moduleId === "demandForm" || moduleId === "vehicleOptions" || moduleId === "vehicleService"; } diff --git a/WonderQ-Admin-UI/src/pages/leads/LeadsPage.tsx b/WonderQ-Admin-UI/src/pages/leads/LeadsPage.tsx new file mode 100644 index 0000000..b0174ad --- /dev/null +++ b/WonderQ-Admin-UI/src/pages/leads/LeadsPage.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { RefreshCw } from "lucide-react"; + +import { getLeads, updateLeadStatus } from "@/api"; +import type { Lead, LeadStatus } from "@/api"; +import { EmptyState } from "@/components/admin/EmptyState"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/select"; +import type { Notify } from "@/types/admin"; + +const statusLabels: Record = { + new: "待处理", + assigned: "已分配", + contacted: "已联系", + planning: "方案沟通", + won: "已成交", + invalid: "无效", +}; + +function formatDate(value?: string | null) { + if (!value) return "未填写"; + return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium" }).format(new Date(value)); +} + +function leadTitle(lead: Lead) { + return lead.vehicleDemand?.vehicleOptionTitle || lead.destination || "用车需求"; +} + +export function LeadsPage({ notify }: { notify: Notify }) { + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [status, setStatus] = useState(""); + const [keyword, setKeyword] = useState(""); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + const [savingId, setSavingId] = useState(""); + + const load = async () => { + setLoading(true); + try { + const result = await getLeads({ leadType: "vehicle", status: status || undefined, keyword }); + setItems(result.items); + setSelectedId((current) => result.items.some((item) => item.id === current) ? current : result.items[0]?.id || ""); + setMessage(""); + } catch (error) { + setMessage(error instanceof Error ? error.message : "用车线索加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, [status]); + + const selected = items.find((item) => item.id === selectedId) || null; + + const saveStatus = async (lead: Lead, nextStatus: LeadStatus) => { + setSavingId(lead.id); + try { + const saved = await updateLeadStatus(lead.id, nextStatus); + setItems((current) => current.map((item) => item.id === saved.id ? saved : item)); + notify({ tone: "success", title: "线索状态已更新", message: `${leadTitle(lead)} 已标记为${statusLabels[nextStatus]}。` }); + } catch (error) { + notify({ tone: "danger", title: "状态更新失败", message: error instanceof Error ? error.message : "请稍后重试" }); + } finally { + setSavingId(""); + } + }; + + return ( +
+ + +
+
+ 用车需求 + 查看已登录用户提交的出行信息,并通过线索状态跟进报价。 +
+ +
+
+ setStatus(event.target.value as LeadStatus | "")} aria-label="线索状态"> + + {Object.entries(statusLabels).map(([value, label]) => )} + + setKeyword(event.target.value)} placeholder="搜索地点、手机号或车型" aria-label="搜索用车线索" /> + +
+
+ + {message ?

{message}

: null} + {loading ? : !items.length ? : ( +
+ {items.map((lead) => ( + + ))} +
+ )} +
+
+ + + {selected ? ( + <> + +
+
+ 需求详情 + 提交于 {formatDate(selected.createdAt)} +
+ 用车 +
+
+ +
+
联系人
{selected.contactName || "未填写"}
+
联系方式
{selected.phone}
+
出行日期
{formatDate(selected.vehicleDemand?.travelDate || selected.travelDate)}
+
出行人数
{selected.vehicleDemand?.peopleCount || selected.peopleCount || "未填写"} 人
+
+
+

行程信息

+

{selected.vehicleDemand?.pickupLocation || "未填写上车点"} → {selected.vehicleDemand?.dropoffLocation || "未填写目的地"}

+

车型:{selected.vehicleDemand?.vehicleOptionTitle || "未指定"} · 行李:{selected.vehicleDemand?.luggageCount ?? "未填写"} 件

+
+
+

备注

+

{selected.vehicleDemand?.specialRequirements || selected.note || "暂无备注"}

+
+ +
+ + ) : } +
+
+ ); +} diff --git a/WonderQ-Admin-UI/src/pages/structure/HomePage.tsx b/WonderQ-Admin-UI/src/pages/structure/HomePage.tsx index e49c6e6..8a68d25 100644 --- a/WonderQ-Admin-UI/src/pages/structure/HomePage.tsx +++ b/WonderQ-Admin-UI/src/pages/structure/HomePage.tsx @@ -34,7 +34,7 @@ export function HomePage({ notify }: { notify: Notify }) { notify={notify} embedded hideRail - moduleIdOverride={moduleId as "heroSlides" | "vehicleOptions"} + moduleIdOverride={moduleId as "heroSlides" | "vehicleOptions" | "vehicleService"} /> ) : (
- {isDemandFormModule ? ( + {isVehicleServiceModule ? ( + <> + +