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

- 新增后台用车服务配置模块,支持维护服务简介、优势与使用流程
- 新增需求线索管理页面,支持筛选、查看与更新用车线索状态
- 新增小程序用车需求页面,优化登录路径与回跳逻辑
- 优化车型卡片跳转与用车需求提交功能
- 新增服务端API与数据处理逻辑,完善权限校验
- 更新全站配置与API文档,新增相关测试用例
This commit is contained in:
duanshuwen committed 2026-08-22 21:48:08 +08:00
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)"
+4 -2
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) {
+57
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>
@@ -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>