feat: 新增用车需求服务及线索管理全功能
- 新增后台用车服务配置模块,支持维护服务简介、优势与使用流程 - 新增需求线索管理页面,支持筛选、查看与更新用车线索状态 - 新增小程序用车需求页面,优化登录路径与回跳逻辑 - 优化车型卡片跳转与用车需求提交功能 - 新增服务端API与数据处理逻辑,完善权限校验 - 更新全站配置与API文档,新增相关测试用例
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
"navigationBarTitleText": "路线详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vehicle-demand",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用车需求"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "微信登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/concierge/index",
|
||||
"style": {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
WonderQ-MiniAPP/src/pages/login.vue
Normal file
57
WonderQ-MiniAPP/src/pages/login.vue
Normal 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>
|
||||
239
WonderQ-MiniAPP/src/pages/vehicle-demand.vue
Normal file
239
WonderQ-MiniAPP/src/pages/vehicle-demand.vue
Normal 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>
|
||||
Reference in New Issue
Block a user