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

@@ -12,6 +12,7 @@ import type {
PublicLeadPayload,
PublicSiteConfig,
PublicWanfaResponse,
VehicleLeadPayload,
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
@@ -151,13 +152,18 @@ export async function fetchPublicDetail(key: string) {
return request<PublicDetail>(`/api/public/details/${encodeURIComponent(key)}`);
}
export async function createPublicLead(payload: PublicLeadPayload) {
export async function createPublicLead(payload: PublicLeadPayload, token?: string) {
return request<{ id: string; status: string }>("/api/public/leads", {
method: "POST",
data: payload,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
export async function createVehicleLead(payload: VehicleLeadPayload, token: string) {
return createPublicLead(payload, token);
}
export async function phoneLogin(payload: PhoneLoginPayload) {
return request<AuthSession>("/api/public/auth/phone-login", {
method: "POST",

View File

@@ -46,7 +46,7 @@ export function isLoggedIn() {
}
export function loginPath(redirect = "/pages/mine/index") {
return `/pages/login/index?redirect=${encodeURIComponent(redirect)}`;
return `/pages/login?redirect=${encodeURIComponent(redirect)}`;
}
export function requireLogin(redirect = "/pages/mine/index") {

View File

@@ -10,6 +10,9 @@ import type {
DestinationHero,
PublicHomeCard,
PublicSiteConfig,
PublicVehicleService,
PublicVehicleServiceSection,
PublicVehicleServiceStep,
VehicleOption,
} from "./types";
@@ -72,6 +75,33 @@ function normalizeDemandForm(items: PublicSiteConfig["demandForm"], fallback: De
};
}
function normalizeVehicleServiceSection(item: unknown): PublicVehicleServiceSection | null {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
return title && description ? { title, description } : null;
}
function normalizeVehicleServiceStep(item: unknown): PublicVehicleServiceStep | null {
return normalizeVehicleServiceSection(item);
}
function normalizeVehicleService(item: PublicSiteConfig["vehicleService"], fallback: PublicVehicleService): PublicVehicleService {
if (!item || item.isActive === false) return fallback;
const serviceSections = (item.serviceSections ?? []).map(normalizeVehicleServiceSection).filter(Boolean) as PublicVehicleServiceSection[];
const processSteps = (item.processSteps ?? []).map(normalizeVehicleServiceStep).filter(Boolean) as PublicVehicleServiceStep[];
const advantages = (item.advantages ?? []).filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean);
return {
id: item.id,
introTitle: item.intro?.trim() && item.introTitle?.trim() ? item.introTitle.trim() : fallback.introTitle,
intro: item.intro?.trim() || fallback.intro,
serviceSections: serviceSections.length ? serviceSections : fallback.serviceSections,
advantages: advantages.length ? advantages : fallback.advantages,
processSteps: processSteps.length ? processSteps : fallback.processSteps,
};
}
export function normalizeSiteConfig(siteConfig: PublicSiteConfig, fallback: AppData): AppData {
return {
heroSlides: siteConfig.heroSlides?.length ? siteConfig.heroSlides.map((slide) => ({ id: slide.id, image: slide.image, kicker: slide.kicker, title: slide.title, targetType: slide.targetType, targetValue: slide.targetValue })) : fallback.heroSlides,
@@ -80,6 +110,7 @@ export function normalizeSiteConfig(siteConfig: PublicSiteConfig, fallback: AppD
demandFeatureCards: normalizeDemandFeatureCards(siteConfig.demandFeatureCards, fallback.demandFeatureCards),
demandForm: normalizeDemandForm(siteConfig.demandForm, fallback.demandForm),
vehicleOptions: normalizeHomeCards<VehicleOption>(siteConfig.vehicleOptions, fallback.vehicleOptions),
vehicleService: normalizeVehicleService(siteConfig.vehicleService, fallback.vehicleService),
};
}
@@ -104,6 +135,26 @@ const fallbackDemandForm: DemandFormConfig = {
submitLabel: "提交出行需求",
chips: ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"],
};
const fallbackVehicleService: PublicVehicleService = {
introTitle: "旅游出行专属用车服务",
intro: "覆盖景区接送、短途包车、旅途代步和多人出行场景。提交需求后由管家联系确认车型与服务安排。",
serviceSections: [
{ title: "旅游专属包车", description: "适配家庭出游、朋友结伴和小团体旅行,支持半天或全天包车,行程可按需求沟通调整。" },
{ title: "多车型灵活选择", description: "舒适轿车、SUV、商务车等车型按人数和行李情况匹配,让不同规模的旅行都能舒适出行。" },
],
advantages: [
"专为旅游定制:贴合出游场景,行程灵活",
"车型齐全:按人数和行李匹配出行方案",
"省心便捷:线上提交需求,管家快速跟进",
"出行安心:服务安排清晰,行程有人跟进",
],
processSteps: [
{ title: "提交需求", description: "填写日期、地点、人数和车型偏好。" },
{ title: "管家联系", description: "管家根据需求沟通车辆和服务安排。" },
{ title: "确认服务", description: "确认行程细节与报价后按约定出行。" },
{ title: "安心出行", description: "按确认方案完成接送或包车服务。" },
],
};
export const fallbackData: AppData = {
heroSlides: fallbackHeroSlides,
@@ -112,6 +163,7 @@ export const fallbackData: AppData = {
demandFeatureCards: fallbackDemandFeatureCards,
demandForm: fallbackDemandForm,
vehicleOptions: fallbackVehicleOptions.map((option) => ({ ...option })),
vehicleService: fallbackVehicleService,
};
export function getSubmitErrorMessage(error: unknown) {

View File

@@ -44,6 +44,15 @@ export function goConcierge() {
uni.switchTab({ url: "/pages/concierge/index" });
}
export function goVehicleDemand(vehicleOptionId?: string) {
const path = "/pages/vehicle-demand";
const query = vehicleOptionId ? { vehicleOptionId } : undefined;
const redirect = withQuery(path, query);
if (requireLogin(redirect)) {
go(path, query);
}
}
export function goWildArchiveDetail(archiveId: string) {
go("/pages/wild-archives/detail", { id: archiveId });
}

View File

@@ -1,10 +1,11 @@
import { reactive } from "vue";
import { createPublicLead, fetchPublicContent, fetchPublicHome } from "./api";
import { createPublicLead, createVehicleLead, fetchPublicContent, fetchPublicHome } from "./api";
import { getAuthSession } from "./auth";
import { fallbackData, getSubmitErrorMessage, normalizeSiteConfig } from "./data";
import { homeTeamBuildingFallback, normalizeHomeTeamBuildings } from "@/pages/home/components/homeTeamBuildingData";
import { homeWildArchiveFallback, normalizeHomeWildArchives } from "@/pages/home/components/homeWildArchivesData";
import { normalizeHomeWanfaRecommendations } from "@/pages/home/components/homeWanfaRecommendationData";
import type { AppData, LeadSubmitResult, PublicLeadPayload } from "./types";
import type { AppData, LeadSubmitResult, PublicLeadPayload, VehicleLeadPayload } from "./types";
import type { HomeWanfaRecommendation } from "@/pages/home/components/homeWanfaRecommendationData";
export const appState = reactive({
@@ -70,3 +71,16 @@ export async function submitLead(payload: PublicLeadPayload, openSupportAfterSub
return { ok: false, message: getSubmitErrorMessage(error) };
}
}
export async function submitVehicleDemand(payload: VehicleLeadPayload, openSupportAfterSubmit = true): Promise<LeadSubmitResult> {
const session = getAuthSession();
if (!session) return { ok: false, message: "请先完成微信授权登录。" };
try {
await createVehicleLead(payload, session.token);
if (openSupportAfterSubmit) uni.showToast({ title: "需求已提交", icon: "none" });
return { ok: true, message: "已收到用车需求,管家会尽快联系你。" };
} catch (error) {
return { ok: false, message: getSubmitErrorMessage(error) };
}
}

View File

@@ -51,6 +51,18 @@ export type PublicDemandForm = {
};
export type PublicHomeCard = { id: string; title: string; description?: string | null; image?: string | null; isActive?: boolean; sortOrder?: number };
export type PublicVehicleServiceSection = { title: string; description: string };
export type PublicVehicleServiceStep = { title: string; description: string };
export type PublicVehicleService = {
id?: string;
introTitle: string;
intro: string;
serviceSections: PublicVehicleServiceSection[];
advantages: string[];
processSteps: PublicVehicleServiceStep[];
isActive?: boolean;
};
export type PublicHomeExperience = {
id: string;
badge: string;
@@ -162,9 +174,12 @@ export type PublicSiteConfig = {
demandFeatureCards?: PublicDemandFeatureCard[];
demandForm?: PublicDemandForm[];
vehicleOptions?: PublicHomeCard[];
vehicleService?: PublicVehicleService | null;
};
export type PublicLeadPayload = {
leadType?: "general" | "vehicle";
contactName?: string;
destination?: string;
phone: string;
travelDate?: string;
@@ -173,6 +188,26 @@ export type PublicLeadPayload = {
budgetMax?: number;
note?: string;
sourcePage?: string;
vehicleDemand?: VehicleDemandPayload;
};
export type VehicleDemandPayload = {
serviceType: "charter" | "transfer";
charterDuration?: "halfDay" | "fullDay";
travelDate: string;
pickupTime?: string;
pickupLocation: string;
dropoffLocation: string;
peopleCount: number;
luggageCount?: number;
vehicleOptionId?: string | null;
vehicleOptionTitle?: string | null;
specialRequirements?: string;
};
export type VehicleLeadPayload = Omit<PublicLeadPayload, "leadType" | "vehicleDemand"> & {
leadType: "vehicle";
vehicleDemand: VehicleDemandPayload;
};
export type HeroSlide = { id?: string; image: string; kicker?: string | null; title: string; action?: string | null; targetType?: string | null; targetValue?: string | null };
@@ -189,5 +224,6 @@ export type AppData = {
demandFeatureCards: DemandFeatureCard[];
demandForm: DemandFormConfig;
vehicleOptions: VehicleOption[];
vehicleService: PublicVehicleService;
};
export type LeadSubmitResult = { ok: boolean; message?: string };

View File

@@ -19,6 +19,19 @@
"navigationBarTitleText": "路线详情"
}
},
{
"path": "pages/vehicle-demand",
"style": {
"navigationBarTitleText": "用车需求"
}
},
{
"path": "pages/login",
"style": {
"navigationBarTitleText": "微信登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/concierge/index",
"style": {

View File

@@ -8,7 +8,7 @@
<view class="mx-[8.5%] grid grid-cols-2 gap-3">
<view
v-for="vehicle in vehicleOptions"
:key="vehicle.title"
:key="vehicle.id || vehicle.title"
class="min-w-0 overflow-hidden rounded-[12px] border border-[#e8e1d7] bg-white tap-feedback active:border-[#d3b890] active:bg-[#fffaf2]"
:aria-label="`${vehicle.title},${vehicle.description}`"
@click="$emit('select', vehicle)"

View File

@@ -23,8 +23,10 @@ import HomeVehicleGrid from "./components/HomeVehicleGrid.vue";
import type { HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData";
import type { HomeWildArchive } from "./components/homeWildArchivesData";
import type { HomeWanfaRecommendation } from "./components/homeWanfaRecommendationData";
import type { VehicleOption } from "@/lib/types";
import {
goTeamBuildingDetail,
goVehicleDemand,
goWanfaRouteDetail,
goWildArchiveDetail,
goWildArchives,
@@ -45,8 +47,8 @@ function openWanfaRecommendation(item: HomeWanfaRecommendation) {
}
}
function openVehicle(item: any) {
function openVehicle(item: VehicleOption) {
goVehicleDemand(item.id);
}
function openWildArchive(item: HomeWildArchive) {

View File

@@ -0,0 +1,57 @@
<template>
<view class="wq-page h-screen overflow-hidden">
<view class="wq-phone h-screen overflow-hidden">
<MineGuest @login="handleLogin" />
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
import { loginWithPhoneCode } from "@/lib/auth";
import MineGuest from "@/pages/mine/components/MineGuest.vue";
type PhoneLoginEvent = { detail?: { code?: string } };
const redirectUrl = ref("/pages/mine/index");
const isLoggingIn = ref(false);
onLoad((options) => {
if (typeof options?.redirect === "string" && options.redirect) {
redirectUrl.value = decodeURIComponent(options.redirect);
}
});
async function handleLogin(event: PhoneLoginEvent) {
const code = event.detail?.code?.trim();
if (!code || isLoggingIn.value) {
if (!code) uni.showToast({ title: "请完成微信手机号授权", icon: "none" });
return;
}
isLoggingIn.value = true;
uni.showLoading({ title: "登录中" });
try {
await loginWithPhoneCode(code);
uni.showToast({ title: "登录成功", icon: "none" });
setTimeout(() => continueAfterLogin(), 250);
} catch (error) {
uni.showToast({
title: error instanceof Error && error.message ? error.message : "微信登录失败,请重试",
icon: "none",
});
} finally {
isLoggingIn.value = false;
uni.hideLoading();
}
}
function continueAfterLogin() {
if (redirectUrl.value === "/pages/mine/index") {
uni.switchTab({ url: redirectUrl.value });
return;
}
uni.redirectTo({ url: redirectUrl.value });
}
</script>

View File

@@ -0,0 +1,239 @@
<template>
<view class="wq-page min-h-screen bg-[#f7f4ef] pb-8">
<view class="wq-phone">
<scroll-view scroll-y class="min-h-screen" :show-scrollbar="false">
<view class="px-5 pb-10 pt-6">
<view class="mb-6">
<text class="block text-[11px] font-semibold tracking-[0.22em] text-[#a45a3d]">WONDERQ VEHICLE SERVICE</text>
<text class="mt-2 block font-serif text-[26px] font-semibold leading-8 text-[#1f2529]">{{ appState.data.vehicleService.introTitle }}</text>
<text class="mt-3 block text-[13px] leading-6 text-[#6f6e69]">{{ appState.data.vehicleService.intro }}</text>
</view>
<view class="mb-5 grid gap-3">
<view v-for="section in appState.data.vehicleService.serviceSections" :key="section.title" class="rounded-[14px] border border-[#e7e0d7] bg-white p-4 shadow-[0_6px_18px_rgba(67,52,38,0.04)]">
<text class="block text-[15px] font-semibold text-[#252b2e]">{{ section.title }}</text>
<text class="mt-2 block text-[12px] leading-5 text-[#77746e]">{{ section.description }}</text>
</view>
</view>
<view class="mb-5 rounded-[14px] border border-[#e7e0d7] bg-white p-4">
<text class="block text-[15px] font-semibold text-[#252b2e]">服务优势</text>
<view class="mt-3 grid gap-2">
<view v-for="advantage in appState.data.vehicleService.advantages" :key="advantage" class="flex gap-2 text-[12px] leading-5 text-[#77746e]">
<text class="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[#a45a3d]" />
<text>{{ advantage }}</text>
</view>
</view>
</view>
<view class="mb-6 rounded-[14px] border border-[#e7e0d7] bg-white p-4">
<text class="block text-[15px] font-semibold text-[#252b2e]">使用流程</text>
<view class="mt-3 grid gap-3">
<view v-for="(step, index) in appState.data.vehicleService.processSteps" :key="step.title" class="flex gap-3">
<view class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#f4e5dc] text-[11px] font-semibold text-[#a45a3d]">{{ index + 1 }}</view>
<view>
<text class="block text-[13px] font-semibold text-[#303437]">{{ step.title }}</text>
<text class="mt-1 block text-[12px] leading-5 text-[#77746e]">{{ step.description }}</text>
</view>
</view>
</view>
</view>
<view class="rounded-[16px] border border-[#e3d9cf] bg-white p-4 shadow-[0_8px_24px_rgba(67,52,38,0.05)]">
<view class="mb-4">
<text class="block text-[18px] font-semibold text-[#202629]">提交用车需求</text>
<text class="mt-1 block text-[12px] leading-5 text-[#77746e]">填写后由管家联系确认车型、服务安排和报价。</text>
</view>
<view v-if="selectedVehicle" class="mb-4 flex items-center gap-3 rounded-[12px] bg-[#faf5ef] p-3">
<image class="h-14 w-16 rounded-[8px] bg-[#ebe8e2]" :src="selectedVehicle.image" mode="aspectFill" />
<view class="min-w-0 flex-1">
<text class="block text-[13px] font-semibold text-[#2c3032]">{{ selectedVehicle.title }}</text>
<text class="mt-1 block text-[11px] leading-4 text-[#77746e]">{{ selectedVehicle.description }}</text>
</view>
</view>
<view class="grid gap-4">
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">联系人姓名</text>
<input v-model="form.contactName" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" placeholder="请输入联系人姓名" maxlength="40" />
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">联系电话</text>
<input v-model="form.phone" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" type="number" placeholder="请输入手机号" maxlength="20" />
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">服务类型</text>
<view class="grid grid-cols-2 gap-2">
<view v-for="option in serviceTypes" :key="option.value" class="flex h-11 items-center justify-center rounded-[10px] border text-[13px] font-medium transition-colors" :class="form.serviceType === option.value ? 'border-[#a45a3d] bg-[#fff1ea] text-[#a45a3d]' : 'border-[#e5ddd3] bg-[#fffdfb] text-[#6f6a64]'" @click="form.serviceType = option.value">
<text>{{ option.label }}</text>
</view>
</view>
</view>
<view v-if="form.serviceType === 'charter'">
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">包车时长</text>
<view class="grid grid-cols-2 gap-2">
<view v-for="option in charterDurations" :key="option.value" class="flex h-11 items-center justify-center rounded-[10px] border text-[13px] font-medium transition-colors" :class="form.charterDuration === option.value ? 'border-[#a45a3d] bg-[#fff1ea] text-[#a45a3d]' : 'border-[#e5ddd3] bg-[#fffdfb] text-[#6f6a64]'" @click="form.charterDuration = option.value">
<text>{{ option.label }}</text>
</view>
</view>
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">出行日期</text>
<picker mode="date" :value="form.travelDate" @change="changeDate">
<view class="mt-2 flex h-11 items-center rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#6f6a64]">{{ form.travelDate || '请选择出行日期' }}</view>
</picker>
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">预计出发时间(选填)</text>
<picker mode="time" :value="form.pickupTime" @change="changeTime">
<view class="mt-2 flex h-11 items-center rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#6f6a64]">{{ form.pickupTime || '请选择出发时间' }}</view>
</picker>
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">上车地点</text>
<input v-model="form.pickupLocation" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" placeholder="例如:贵阳北站" maxlength="100" />
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">目的地/下车地点</text>
<input v-model="form.dropoffLocation" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" placeholder="例如:黄果树景区" maxlength="100" />
</view>
<view class="grid grid-cols-2 gap-3">
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">出行人数</text>
<input v-model="form.peopleCount" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" type="number" placeholder="人数" maxlength="4" />
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">行李数量(选填)</text>
<input v-model="form.luggageCount" class="mt-2 h-11 w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] px-3 text-[13px] text-[#292d2f]" type="number" placeholder="件数" maxlength="4" />
</view>
</view>
<view>
<text class="block text-[12px] font-medium leading-5 text-[#5f5b56]">特殊要求(选填)</text>
<textarea v-model="form.specialRequirements" class="mt-2 min-h-[96px] w-full rounded-[10px] border border-[#e5ddd3] bg-[#fffdfb] p-3 text-[13px] leading-5 text-[#292d2f]" placeholder="儿童座椅、老人同行或其他需求" maxlength="1000" />
</view>
</view>
<view v-if="errorMessage" class="mt-4 rounded-[10px] bg-[#fff0ed] px-3 py-2 text-[12px] leading-5 text-[#b44d3d]">{{ errorMessage }}</view>
<view v-if="submitted" class="mt-4 rounded-[10px] bg-[#eef7f1] px-3 py-3 text-[12px] leading-5 text-[#39724f]">需求已提交,管家会尽快联系你确认服务安排。</view>
<button class="tap-feedback mt-5 h-11 rounded-full border-0 bg-[#a45a3d] text-[14px] font-semibold text-white" :disabled="isSubmitting || submitted" @click="submit">
{{ isSubmitting ? '提交中…' : submitted ? '已提交' : '提交需求' }}
</button>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { redirectToLogin } from "@/lib/auth";
import { goBack } from "@/lib/navigation";
import { appState, loadAppData, submitVehicleDemand } from "@/lib/store";
import type { VehicleDemandPayload, VehicleOption, VehicleLeadPayload } from "@/lib/types";
type ServiceType = VehicleDemandPayload["serviceType"];
type Duration = NonNullable<VehicleDemandPayload["charterDuration"]>;
type ChangeEvent = { detail?: { value?: string } };
const form = reactive({
contactName: "",
phone: "",
serviceType: "charter" as ServiceType,
charterDuration: "fullDay" as Duration,
travelDate: "",
pickupTime: "",
pickupLocation: "",
dropoffLocation: "",
peopleCount: "",
luggageCount: "",
specialRequirements: "",
});
const vehicleOptionId = ref<string>();
const isSubmitting = ref(false);
const submitted = ref(false);
const errorMessage = ref("");
const serviceTypes = [
{ value: "charter" as const, label: "旅游包车" },
{ value: "transfer" as const, label: "接送服务" },
];
const charterDurations = [
{ value: "halfDay" as const, label: "半天包车" },
{ value: "fullDay" as const, label: "全天包车" },
];
const selectedVehicle = computed<VehicleOption | undefined>(() => appState.data.vehicleOptions.find((item) => item.id === vehicleOptionId.value));
onLoad((options) => {
vehicleOptionId.value = typeof options?.vehicleOptionId === "string" ? options.vehicleOptionId : undefined;
if (!redirectToLogin(`/pages/vehicle-demand?vehicleOptionId=${encodeURIComponent(vehicleOptionId.value || "")}`)) return;
});
onShow(() => {
void loadAppData();
});
function changeDate(event: ChangeEvent) {
form.travelDate = event.detail?.value || "";
}
function changeTime(event: ChangeEvent) {
form.pickupTime = event.detail?.value || "";
}
function validate(): string | null {
if (!form.contactName.trim()) return "请填写联系人姓名";
if (!/^1\d{10}$/.test(form.phone.trim())) return "请输入正确的手机号";
if (!form.travelDate) return "请选择出行日期";
if (!form.pickupLocation.trim()) return "请填写上车地点";
if (!form.dropoffLocation.trim()) return "请填写目的地或下车地点";
const peopleCount = Number(form.peopleCount);
if (!Number.isInteger(peopleCount) || peopleCount < 1) return "请填写正确的出行人数";
if (form.luggageCount && (!Number.isInteger(Number(form.luggageCount)) || Number(form.luggageCount) < 0)) return "请填写正确的行李数量";
return null;
}
async function submit() {
if (isSubmitting.value || submitted.value) return;
errorMessage.value = validate() || "";
if (errorMessage.value) return;
const demand: VehicleDemandPayload = {
serviceType: form.serviceType,
charterDuration: form.serviceType === "charter" ? form.charterDuration : undefined,
travelDate: form.travelDate,
pickupTime: form.pickupTime || undefined,
pickupLocation: form.pickupLocation.trim(),
dropoffLocation: form.dropoffLocation.trim(),
peopleCount: Number(form.peopleCount),
luggageCount: form.luggageCount ? Number(form.luggageCount) : undefined,
vehicleOptionId: vehicleOptionId.value || null,
vehicleOptionTitle: selectedVehicle.value?.title || null,
specialRequirements: form.specialRequirements.trim() || undefined,
};
const payload: VehicleLeadPayload = {
leadType: "vehicle",
contactName: form.contactName.trim(),
phone: form.phone.trim(),
note: form.specialRequirements.trim() || undefined,
sourcePage: "home-vehicle",
vehicleDemand: demand,
};
isSubmitting.value = true;
const result = await submitVehicleDemand(payload, false);
if (result.ok) {
submitted.value = true;
uni.showToast({ title: "需求已提交", icon: "none" });
} else {
errorMessage.value = result.message || "提交失败,请重试";
}
isSubmitting.value = false;
}
function handleBack() {
goBack();
}
</script>