feat: 新增用车需求服务及线索管理全功能
- 新增后台用车服务配置模块,支持维护服务简介、优势与使用流程 - 新增需求线索管理页面,支持筛选、查看与更新用车线索状态 - 新增小程序用车需求页面,优化登录路径与回跳逻辑 - 优化车型卡片跳转与用车需求提交功能 - 新增服务端API与数据处理逻辑,完善权限校验 - 更新全站配置与API文档,新增相关测试用例
This commit is contained in:
1 parent
b7a81964c9
commit
d42f69bd47
36 files changed
+1290
-33
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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