feat: 新增用车需求服务及线索管理全功能

- 新增后台用车服务配置模块,支持维护服务简介、优势与使用流程
- 新增需求线索管理页面,支持筛选、查看与更新用车线索状态
- 新增小程序用车需求页面,优化登录路径与回跳逻辑
- 优化车型卡片跳转与用车需求提交功能
- 新增服务端API与数据处理逻辑,完善权限校验
- 更新全站配置与API文档,新增相关测试用例
This commit is contained in:
duanshuwen
2026-08-22 21:48:08 +08:00
parent b7a81964c9
commit d42f69bd47
36 changed files with 1290 additions and 33 deletions

View File

@@ -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<SiteConfig>("/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<Lead>(`/api/admin/leads/${leadId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
export async function getWanfaCategories() {
return request<{ categories: WanfaCategory[] }>("/api/admin/wanfa/categories");
}

View File

@@ -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 }) {
<WanfaPage key="wanfa-page" notify={notify} />
) : activeTab === "butler" ? (
<ConciergePage key="concierge-page" notify={notify} />
) : activeTab === "leads" ? (
<LeadsPage key="leads-page" notify={notify} />
) : null}
</section>
<ToastStack

View File

@@ -7,6 +7,7 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
if (moduleId === "demandForm") return "submitLabel";
if (moduleId === "demandHero" || moduleId === "demandFeatureCards") return "title";
if (moduleId === "vehicleOptions") return "title";
if (moduleId === "vehicleService") return "introTitle";
return "title";
}
@@ -39,6 +40,16 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
if (moduleId === "vehicleOptions") {
return { title: "", description: "", image: "", isActive: true, sortOrder };
}
if (moduleId === "vehicleService") {
return {
introTitle: "万趣用车",
intro: "旅游出行专属用车服务,管家协助确认合适车型与行程安排。",
serviceSections: [],
advantages: [],
processSteps: [],
isActive: true,
};
}
return { title: "", isActive: false, sortOrder };
}
@@ -104,6 +115,20 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "vehicleService") {
payload.introTitle = draft.introTitle?.trim();
payload.intro = draft.intro?.trim() || "";
payload.serviceSections = (draft.serviceSections ?? [])
.map((item) => ({ 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";
}

View File

@@ -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<LeadStatus, string> = {
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<Lead[]>([]);
const [selectedId, setSelectedId] = useState("");
const [status, setStatus] = useState<LeadStatus | "">("");
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 (
<section className="work-area grid min-h-0 gap-4 p-4 md:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
<Card className="min-h-0 overflow-hidden">
<CardHeader className="border-b border-slate-100 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-xl">用车需求</CardTitle>
<CardDescription>查看已登录用户提交的出行信息,并通过线索状态跟进报价。</CardDescription>
</div>
<Button variant="outline" size="icon" onClick={() => void load()} disabled={loading} aria-label="刷新用车线索">
<RefreshCw size={16} className={loading ? "animate-spin" : ""} />
</Button>
</div>
<div className="grid gap-2 pt-3 sm:grid-cols-[160px_minmax(0,1fr)_auto]">
<NativeSelect value={status} onChange={(event) => setStatus(event.target.value as LeadStatus | "")} aria-label="线索状态">
<option value="">全部状态</option>
{Object.entries(statusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}
</NativeSelect>
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索地点、手机号或车型" aria-label="搜索用车线索" />
<Button variant="outline" onClick={() => void load()}>查询</Button>
</div>
</CardHeader>
<CardContent className="min-h-0 overflow-auto p-3">
{message ? <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{message}</p> : null}
{loading ? <EmptyState text="正在读取用车线索" /> : !items.length ? <EmptyState text="暂无符合条件的用车需求" /> : (
<div className="grid gap-2">
{items.map((lead) => (
<button type="button" key={lead.id} onClick={() => setSelectedId(lead.id)} className={`grid gap-2 rounded-xl border p-4 text-left transition ${selectedId === lead.id ? "border-teal-500 bg-teal-50/60" : "border-slate-200 bg-white hover:border-slate-300"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-950">{lead.contactName || "未填写联系人"}</p>
<p className="truncate text-sm text-slate-500">{leadTitle(lead)} · {lead.phone}</p>
</div>
<Badge variant={lead.status === "invalid" ? "muted" : lead.status === "won" ? "success" : "default"}>{statusLabels[lead.status]}</Badge>
</div>
<p className="text-xs text-slate-500">{formatDate(lead.vehicleDemand?.travelDate || lead.travelDate)} · {lead.vehicleDemand?.peopleCount || lead.peopleCount || "未填写"} 人 · {lead.vehicleDemand?.pickupLocation || "未填写上车点"} → {lead.vehicleDemand?.dropoffLocation || "未填写目的地"}</p>
</button>
))}
</div>
)}
</CardContent>
</Card>
<Card className="min-h-0 overflow-auto">
{selected ? (
<>
<CardHeader className="border-b border-slate-100 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-xl">需求详情</CardTitle>
<CardDescription>提交于 {formatDate(selected.createdAt)}</CardDescription>
</div>
<Badge variant="success">用车</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-5 p-5 text-sm">
<dl className="grid gap-3 sm:grid-cols-2">
<div><dt className="text-slate-500">联系人</dt><dd className="mt-1 font-medium">{selected.contactName || "未填写"}</dd></div>
<div><dt className="text-slate-500">联系方式</dt><dd className="mt-1 font-medium">{selected.phone}</dd></div>
<div><dt className="text-slate-500">出行日期</dt><dd className="mt-1 font-medium">{formatDate(selected.vehicleDemand?.travelDate || selected.travelDate)}</dd></div>
<div><dt className="text-slate-500">出行人数</dt><dd className="mt-1 font-medium">{selected.vehicleDemand?.peopleCount || selected.peopleCount || "未填写"} 人</dd></div>
</dl>
<div className="rounded-xl bg-slate-50 p-4">
<p className="font-semibold text-slate-900">行程信息</p>
<p className="mt-2 leading-6 text-slate-600">{selected.vehicleDemand?.pickupLocation || "未填写上车点"} → {selected.vehicleDemand?.dropoffLocation || "未填写目的地"}</p>
<p className="mt-1 text-slate-600">车型:{selected.vehicleDemand?.vehicleOptionTitle || "未指定"} · 行李:{selected.vehicleDemand?.luggageCount ?? "未填写"} 件</p>
</div>
<div>
<p className="font-semibold text-slate-900">备注</p>
<p className="mt-2 whitespace-pre-wrap leading-6 text-slate-600">{selected.vehicleDemand?.specialRequirements || selected.note || "暂无备注"}</p>
</div>
<label className="grid gap-2 font-medium text-slate-700">
跟进状态
<NativeSelect value={selected.status} disabled={savingId === selected.id} onChange={(event) => void saveStatus(selected, event.target.value as LeadStatus)}>
{Object.entries(statusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}
</NativeSelect>
</label>
</CardContent>
</>
) : <EmptyState text="请选择一条用车需求查看详情" />}
</Card>
</section>
);
}

View File

@@ -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"}
/>
) : (
<HomeContentPage

View File

@@ -67,6 +67,11 @@ const pageSpecs: {
label: "万趣用车",
hint: "维护首页用车卡片标题、描述、封面图、启用状态和排序。",
},
{
id: "vehicleService",
label: "用车服务",
hint: "维护用车需求页的服务说明、优势和提交流程。",
},
],
},
{
@@ -118,9 +123,10 @@ export function StructurePage({
? moduleItems(config, moduleId)
: [];
const isCardContentModule = moduleId === "vehicleOptions";
const isVehicleServiceModule = moduleId === "vehicleService";
const isDemandFormModule = moduleId === "demandForm";
const canReorderSiteItems = moduleEditable(moduleId) && !isDemandFormModule;
const canCreateSiteItem = moduleEditable(moduleId) && (!isDemandFormModule || editableItems.length === 0);
const canReorderSiteItems = moduleEditable(moduleId) && !isDemandFormModule && !isVehicleServiceModule;
const canCreateSiteItem = moduleEditable(moduleId) && ((!isDemandFormModule && !isVehicleServiceModule) || editableItems.length === 0);
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
const selectedItem = isCreatingSiteItem
? undefined
@@ -147,6 +153,11 @@ export function StructurePage({
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,
introTitle: "introTitle" in item ? item.introTitle : undefined,
intro: "intro" in item ? item.intro : undefined,
serviceSections: "serviceSections" in item && Array.isArray(item.serviceSections) ? item.serviceSections : undefined,
advantages: "advantages" in item && Array.isArray(item.advantages) ? item.advantages : undefined,
processSteps: "processSteps" in item && Array.isArray(item.processSteps) ? item.processSteps : undefined,
isActive: "isActive" in item ? item.isActive : undefined,
sortOrder: itemSortOrder(item),
});
@@ -543,6 +554,7 @@ function SiteItemEditor({
if (moduleId === "demandFeatureCards") return "卡片标题";
if (moduleId === "demandForm") return "提交按钮文案";
if (moduleId === "vehicleOptions") return "用车标题";
if (moduleId === "vehicleService") return "服务标题";
return "配置标题";
})();
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
@@ -554,7 +566,8 @@ function SiteItemEditor({
isDemandHeroModule ||
isDemandFeatureCardModule ||
isDemandFormModule;
const isTextOnlyModule = isDemandTextModule;
const isVehicleServiceModule = moduleId === "vehicleService";
const isTextOnlyModule = isDemandTextModule || isVehicleServiceModule;
const isCardContentModule = moduleId === "vehicleOptions";
const editorHint = isCreating
? `创建后会进入「${moduleLabel}」的数据列表`
@@ -578,7 +591,64 @@ function SiteItemEditor({
</header>
<div className="admin-disclosure-stack">
<AdminDisclosure title="展示内容">
{isDemandFormModule ? (
{isVehicleServiceModule ? (
<>
<label>
服务标题
<Input
value={draft.introTitle ?? ""}
onChange={(event) => onDraft({ ...draft, introTitle: event.target.value })}
/>
</label>
<label>
板块简介
<Textarea
value={draft.intro ?? ""}
onChange={(event) => onDraft({ ...draft, intro: event.target.value })}
rows={4}
/>
</label>
<label>
服务说明(每行一项,格式:标题 | 说明)
<Textarea
value={(draft.serviceSections ?? []).map((item) => `${item.title} | ${item.description}`).join("\n")}
onChange={(event) => onDraft({
...draft,
serviceSections: event.target.value.split(/\r?\n/).map((line) => {
const [title = "", ...description] = line.split("|");
return { title: title.trim(), description: description.join("|").trim() };
}).filter((item) => item.title || item.description),
})}
rows={6}
/>
</label>
<label>
服务优势(每行一项)
<Textarea
value={(draft.advantages ?? []).join("\n")}
onChange={(event) => onDraft({
...draft,
advantages: event.target.value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
})}
rows={5}
/>
</label>
<label>
使用流程(每行一项,格式:步骤 | 说明)
<Textarea
value={(draft.processSteps ?? []).map((item) => `${item.title} | ${item.description}`).join("\n")}
onChange={(event) => onDraft({
...draft,
processSteps: event.target.value.split(/\r?\n/).map((line) => {
const [title = "", ...description] = line.split("|");
return { title: title.trim(), description: description.join("|").trim() };
}).filter((item) => item.title || item.description),
})}
rows={6}
/>
</label>
</>
) : isDemandFormModule ? (
<>
<div className="form-grid compact-grid">
<label>

View File

@@ -1,6 +1,6 @@
import type { SiteConfig, SiteModule } from "@/api";
export type Tab = "home" | "play" | "butler";
export type Tab = "home" | "play" | "butler" | "leads";
export type PageId = "home" | "play";
export type ModuleId = SiteModule;
@@ -19,4 +19,5 @@ export type EditableSiteItem =
| SiteConfig["demandHero"][number]
| SiteConfig["demandFeatureCards"][number]
| SiteConfig["demandForm"][number]
| SiteConfig["vehicleOptions"][number];
| SiteConfig["vehicleOptions"][number]
| SiteConfig["vehicleService"][number];