Ver código fonte

feat: 车牌绑定支持多车辆管理,优化输入体验

车辆绑定页重构:输入框自动跳转下一格,末位绿色闪电图标标识新能源位,
已绑定车辆以列表展示并标记默认车辆,空态展示引导提示。
后端新增车辆CRUD接口,修复 updateUser 中车牌比对字段错误及 getMe 多默认车辆异常。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 dia atrás
pai
commit
5fe34aae36

+ 39 - 2
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/CarsController.java

@@ -1,7 +1,11 @@
 package com.kym.miniapp.controller;
 
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import cn.dev33.satoken.stp.StpUtil;
+import com.kym.common.R;
+import com.kym.service.CarsService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
 
 /**
  * <p>
@@ -15,4 +19,37 @@ import org.springframework.web.bind.annotation.RestController;
 @RequestMapping("/cars")
 public class CarsController {
 
+    private final CarsService carsService;
+
+    public CarsController(CarsService carsService) {
+        this.carsService = carsService;
+    }
+
+    @GetMapping("/list")
+    public R<?> list() {
+        var userId = StpUtil.getLoginIdAsLong();
+        return R.success(carsService.listUserCars(userId));
+    }
+
+    @PostMapping("/save")
+    public R<?> save(@RequestBody Map<String, String> body) {
+        var userId = StpUtil.getLoginIdAsLong();
+        var plateNo = body.get("plateNo");
+        carsService.saveCar(userId, plateNo);
+        return R.success();
+    }
+
+    @PostMapping("/setDefault/{id}")
+    public R<?> setDefault(@PathVariable Long id) {
+        var userId = StpUtil.getLoginIdAsLong();
+        carsService.setDefault(userId, id);
+        return R.success();
+    }
+
+    @DeleteMapping("/delete/{id}")
+    public R<?> delete(@PathVariable Long id) {
+        var userId = StpUtil.getLoginIdAsLong();
+        carsService.deleteCar(userId, id);
+        return R.success();
+    }
 }

+ 379 - 164
car-wash-mp/src/pages-user/profile/index.vue

@@ -1,78 +1,128 @@
 <template>
   <view class="plate-page">
     <uv-navbar title="我的车辆" bgColor="#C6171E" leftIconColor="#FFFFFF" :titleStyle="{ color: '#FFFFFF' }" :autoBack="true" :placeholder="true"></uv-navbar>
+
     <!-- 车牌输入区域 -->
     <view class="plate-input-section">
-      <view class="plate-input-header">
-        <text class="plate-label">车牌号码:</text>
-        <view class="default-checkbox">
-          <uv-checkbox v-model="isDefault" shape="circle"></uv-checkbox>
-          <text class="default-text">设为默认车辆</text>
-        </view>
+      <view class="section-header">
+        <text class="section-title">绑定新车牌</text>
       </view>
 
       <view class="plate-input-hint">
-        <text>添加车牌后部分站点可享停车减免权益,临牌减免权益请查看站点详细减免规则</text>
+        <text>添加车牌后部分站点可享停车减免权益</text>
       </view>
 
       <!-- 车牌输入框 -->
       <view class="plate-input-container">
-        <!-- 省份输入 -->
         <input class="plate-item plate-region"
                v-model="plateChars[0]"
                maxlength="1"
                type="text"
                inputmode="none"
                readonly
-               @focus="onPlateFocus(0); $event.preventDefault();"
                @click="toggleRegionPicker"
                @touchstart.prevent="toggleRegionPicker"/>
 
-        <!-- 城市输入 -->
-        <input class="plate-item plate-region"
+        <input class="plate-item"
                v-model="plateChars[1]"
+               :focus="focusIndex === 1"
                maxlength="1"
                type="text"
                inputmode="text"
-               @input="onPlateInput"
-               @focus="onPlateFocus(1)"/>
+               @input="onPlateInput(1)"
+               @focus="onPlateFocus(1)"
+               @blur="onPlateBlur"/>
 
-        <!-- 分隔符 -->
         <view class="plate-divider">
           <text>·</text>
         </view>
 
-        <!-- 字母数字输入框 -->
-        <input v-for="(char, index) in plateChars.slice(2)"
-               :key="index + 2"
-               class="plate-item"
-               :class="{ 'new-energy': index === 5 }"
-               v-model="plateChars[index + 2]"
-               maxlength="1"
-               placeholder-class="placeholder"
-               :placeholder="index === 5 ? '新能源' : ''"
-               type="text"
-               inputmode="text"
-               @input="onPlateInput"
-               @focus="onPlateFocus(index + 2)"/>
+        <template v-for="(char, index) in plateChars.slice(2)" :key="index + 2">
+          <!-- 最后一位:带闪电图标的新能源标识位 -->
+          <view v-if="index === 5"
+                class="plate-item new-energy"
+                :class="{ 'plate-focus': focusIndex === (index + 2) }">
+            <input class="energy-input"
+                   :focus="focusIndex === (index + 2)"
+                   v-model="plateChars[index + 2]"
+                   maxlength="1"
+                   type="text"
+                   inputmode="text"
+                   @input="onPlateInput(index + 2)"
+                   @focus="onPlateFocus(index + 2)"
+                   @blur="onPlateBlur"/>
+            <image v-if="!plateChars[index + 2]" class="energy-icon-inside" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzUyQzQxQSI+PHBhdGggZD0iTTEzIDJMMyAxNGg2bC0xIDggMTAtMTJoLTZsMS04eiIvPjwvc3ZnPg==" mode="aspectFit" />
+          </view>
+          <!-- 前5位普通输入 -->
+          <input v-else
+                 class="plate-item"
+                 :class="{ 'plate-focus': focusIndex === (index + 2) }"
+                 :focus="focusIndex === (index + 2)"
+                 v-model="plateChars[index + 2]"
+                 maxlength="1"
+                 placeholder-class="placeholder"
+                 type="text"
+                 inputmode="text"
+                 @input="onPlateInput(index + 2)"
+                 @focus="onPlateFocus(index + 2)"
+                 @blur="onPlateBlur"/>
+        </template>
       </view>
 
-      <!-- 保存按钮 -->
       <view class="save-btn-container">
         <uv-button type="primary" color="#C6171E" shape="circle" :disabled="!isPlateValid" @click="savePlate">保存</uv-button>
       </view>
 
-      <!-- 地区选择器 -->
-      <view v-if="showRegionPicker" class="region-picker-container">
-        <view class="region-picker-header">
-          <uv-button type="default" size="small" @click="toggleRegionPicker">完成</uv-button>
+      <view v-if="showRegionPicker" class="region-picker-mask" @click="toggleRegionPicker">
+        <view class="region-picker-container" @click.stop>
+          <view class="region-picker-header">
+            <text class="region-picker-title">选择省份</text>
+            <view class="region-picker-close" @click="toggleRegionPicker">
+              <text>完成</text>
+            </view>
+          </view>
+          <view class="region-grid">
+            <view v-for="region in regions" :key="region"
+                  class="region-item"
+                  :class="{ 'region-selected': plateChars[0] === region }"
+                  @click="selectRegion(region)">
+              <text>{{ region }}</text>
+            </view>
+          </view>
         </view>
+      </view>
+    </view>
 
-        <view class="region-grid">
-          <view v-for="region in regions" :key="region"
-                class="region-item"
-                @click="selectRegion(region)">
-            <text>{{ region }}</text>
+    <!-- 已绑定车辆列表 -->
+    <view class="car-list-section">
+      <view class="section-header">
+        <text class="section-title">已绑定车辆</text>
+        <text class="section-count" v-if="carList.length > 0">{{ carList.length }}辆</text>
+      </view>
+
+      <view v-if="carList.length === 0" class="empty-state">
+        <image class="empty-icon-svg" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0IiBmaWxsPSJub25lIj48cmVjdCB4PSIxMCIgeT0iMjYiIHdpZHRoPSI0NCIgaGVpZ2h0PSIyMCIgcng9IjQiIHN0cm9rZT0iIzk5OSIgc3Ryb2tlLXdpZHRoPSIyLjUiLz48cmVjdCB4PSIxNiIgeT0iMTYiIHdpZHRoPSIzMiIgaGVpZ2h0PSIxNCIgcng9IjYiIHN0cm9rZT0iIzk5OSIgc3Ryb2tlLXdpZHRoPSIyLjUiLz48Y2lyY2xlIGN4PSIyMiIgY3k9IjQ4IiByPSI2IiBzdHJva2U9IiM5OTkiIHN0cm9rZS13aWR0aD0iMi41Ii8+PGNpcmNsZSBjeD0iNDIiIGN5PSI0OCIgcj0iNiIgc3Ryb2tlPSIjOTk5IiBzdHJva2Utd2lkdGg9IjIuNSIvPjxsaW5lIHgxPSIxMCIgeTE9IjI4IiB4Mj0iNiIgeTI9IjI4IiBzdHJva2U9IiM5OTkiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48bGluZSB4MT0iNTQiIHkxPSIyOCIgeDI9IjU4IiB5Mj0iMjgiIHN0cm9rZT0iIzk5OSIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==" mode="aspectFit" />
+        <text class="empty-title">暂无绑定车辆</text>
+        <text class="empty-hint">在上方输入车牌号开始绑定</text>
+      </view>
+
+      <view v-else>
+        <view v-for="(car, index) in carList" :key="car.id" class="car-item" :class="{ 'car-item-last': index === carList.length - 1 }">
+          <view class="car-info">
+            <view class="car-plate-row">
+              <text class="car-plate">{{ car.plateNo }}</text>
+              <view v-if="car.isDefault" class="default-tag">
+                <text>默认</text>
+              </view>
+            </view>
+          </view>
+          <view class="car-actions">
+            <view v-if="!car.isDefault" class="action-btn set-default-btn" @click="handleSetDefault(car)">
+              <text>设为默认</text>
+            </view>
+            <view class="action-btn delete-btn" @click="handleDelete(car)">
+              <text>删除</text>
+            </view>
           </view>
         </view>
       </view>
@@ -82,14 +132,13 @@
 
 <script setup>
 import {onLoad, onShow} from "@dcloudio/uni-app";
-import {ref, computed} from "vue";
-import {body} from "@/utils/https";
+import {ref, computed, nextTick} from "vue";
+import {body, get, del} from "@/utils/https";
 
-const plateNo = ref("未绑定");
 const plateChars = ref(['粤', 'B', '', '', '', '', '', '']);
-const isDefault = ref(false);
-const currentInputIndex = ref(2);
+const focusIndex = ref(2);
 const showRegionPicker = ref(false);
+const carList = ref([]);
 
 const regions = ['京', '津', '渝', '沪', '冀', '晋', '辽', '吉', '黑', '苏', '浙', '皖', '闽', '赣', '鲁', '豫', '鄂', '湘', '粤', '琼', '川', '贵', '云', '陕', '甘', '青', '蒙', '桂', '宁', '新', '藏', '使', '领', '警', '学', '港', '澳'];
 
@@ -98,14 +147,30 @@ const isPlateValid = computed(() => {
   return validChars.length >= 7;
 });
 
-const onPlateInput = () => {
-  for (let i = 0; i < plateChars.value.length; i++) {
-    plateChars.value[i] = plateChars.value[i].toUpperCase();
+let autoAdvancing = false;
+
+const onPlateInput = (index) => {
+  plateChars.value[index] = plateChars.value[index].toUpperCase();
+  if (plateChars.value[index] !== '') {
+    if (index < 7) {
+      autoAdvancing = true;
+      focusIndex.value = -1;
+      nextTick(() => {
+        focusIndex.value = index + 1;
+        autoAdvancing = false;
+      });
+    }
   }
 };
 
 const onPlateFocus = (index) => {
-  currentInputIndex.value = index;
+  focusIndex.value = index;
+};
+
+const onPlateBlur = () => {
+  if (!autoAdvancing) {
+    focusIndex.value = -1;
+  }
 };
 
 const toggleRegionPicker = () => {
@@ -115,69 +180,77 @@ const toggleRegionPicker = () => {
 const selectRegion = (region) => {
   plateChars.value[0] = region;
   toggleRegionPicker();
-  if (plateChars.value[1] === '') {
-    currentInputIndex.value = 1;
-  }
+  nextTick(() => {
+    focusIndex.value = plateChars.value[1] ? 2 : 1;
+  });
 };
 
 const savePlate = () => {
-  const newPlateNo = plateChars.value.filter(char => char !== '').join('');
-  if (newPlateNo.length < 7) {
-    uni.showToast({
-      title: '请输入完整车牌号',
-      icon: 'none'
-    });
+  const validChars = plateChars.value.filter(char => char !== '');
+  if (validChars.length < 7) {
+    uni.showToast({ title: '请输入完整车牌号', icon: 'none' });
     return;
   }
+  const newPlateNo = validChars.join('');
 
-  save({
-    defaultPlateNo: newPlateNo,
+  uni.showLoading({ title: "保存中" });
+  body(`/cars/save`, { plateNo: newPlateNo }).then(() => {
+    uni.hideLoading();
+    uni.showToast({ icon: "success", title: "保存成功" });
+    plateChars.value = ['粤', 'B', '', '', '', '', '', ''];
+    focusIndex.value = 2;
+    fetchCarList();
+  }).catch(() => {
+    uni.hideLoading();
   });
 };
 
-const save = (form) => {
-  uni.showLoading({
-    title: "保存中",
-  });
-  body(`/user/updateUser`, form).then(() => {
+const fetchCarList = () => {
+  get(`/cars/list`).then((data) => {
+    carList.value = data || [];
+    const defaultCar = carList.value.find(c => c.isDefault);
+    if (defaultCar && getApp().globalData.user) {
+      getApp().globalData.user.defaultPlateNo = defaultCar.plateNo;
+    }
+  }).catch(() => {});
+};
+
+const handleSetDefault = (car) => {
+  uni.showLoading({ title: "设置中" });
+  body(`/cars/setDefault/${car.id}`).then(() => {
     uni.hideLoading();
-    getApp().globalData.user.defaultPlateNo = form.defaultPlateNo;
-    plateNo.value = form.defaultPlateNo;
-    uni.showToast({
-      icon: "success",
-      title: "保存成功",
-    });
-    setTimeout(() => {
-      uni.navigateBack();
-    }, 1500);
-  }).catch((err) => {
+    uni.showToast({ icon: "success", title: "已设为默认" });
+    fetchCarList();
+  }).catch(() => {
     uni.hideLoading();
-    uni.showToast({
-      title: "保存失败",
-      icon: "none"
-    });
   });
 };
 
-onLoad(() => {
-  if (getApp().globalData.user) {
-    const user = getApp().globalData.user;
-    plateNo.value = user.defaultPlateNo || "未绑定";
-    if (user.defaultPlateNo) {
-      const plate = user.defaultPlateNo;
-      plateChars.value = ['粤', 'B', '', '', '', '', '', ''];
-      for (let i = 0; i < plate.length && i < plateChars.value.length; i++) {
-        plateChars.value[i] = plate[i];
+const handleDelete = (car) => {
+  uni.showModal({
+    title: '确认删除',
+    content: `确定要删除车辆 ${car.plateNo} 吗?`,
+    success: (res) => {
+      if (res.confirm) {
+        uni.showLoading({ title: "删除中" });
+        del(`/cars/delete/${car.id}`).then(() => {
+          uni.hideLoading();
+          uni.showToast({ icon: "success", title: "已删除" });
+          fetchCarList();
+        }).catch(() => {
+          uni.hideLoading();
+        });
       }
     }
-  }
+  });
+};
+
+onLoad(() => {
+  fetchCarList();
 });
 
 onShow(() => {
-  if (getApp().globalData.user) {
-    const user = getApp().globalData.user;
-    plateNo.value = user.defaultPlateNo || "未绑定";
-  }
+  fetchCarList();
 });
 </script>
 
@@ -186,47 +259,44 @@ onShow(() => {
   background-color: $uni-bg-color-page;
   min-height: 100vh;
   box-sizing: border-box;
-  padding-bottom: 200rpx;
+  padding-bottom: 60rpx;
 }
 
-.plate-input-section {
-  background-color: $uni-bg-color-card;
-  padding: 30rpx;
-  box-sizing: border-box;
-  margin-bottom: 20rpx;
-}
-
-.plate-input-header {
+// Section shared
+.section-header {
   display: flex;
-  align-items: center;
+  align-items: baseline;
   justify-content: space-between;
-  margin-bottom: 10rpx;
+  margin-bottom: 12rpx;
 }
 
-.plate-label {
-  font-size: 32rpx;
-  font-weight: $uni-font-weight-semibold;
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
   color: $uni-text-color;
 }
 
-.default-checkbox {
-  display: flex;
-  align-items: center;
-  gap: 8rpx;
+.section-count {
+  font-size: 24rpx;
+  color: $uni-text-color-hint;
 }
 
-.default-text {
-  font-size: 28rpx;
-  color: $uni-text-color-secondary;
+// 车牌输入区
+.plate-input-section {
+  background-color: $uni-bg-color-card;
+  padding: 28rpx;
+  box-sizing: border-box;
+  margin-bottom: 20rpx;
+  border-radius: 24rpx;
 }
 
 .plate-input-hint {
-  margin-bottom: 30rpx;
+  margin-bottom: 28rpx;
 }
 
 .plate-input-hint text {
-  font-size: 26rpx;
-  color: $uni-text-color-tertiary;
+  font-size: 24rpx;
+  color: $uni-text-color-hint;
   line-height: 1.5;
 }
 
@@ -234,73 +304,94 @@ onShow(() => {
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-bottom: 40rpx;
-  gap: 4rpx;
+  margin-bottom: 24rpx;
+  gap: 8rpx;
 }
 
 .plate-item {
-  width: 64rpx;
+  width: 68rpx;
   height: 88rpx;
   border: 2rpx solid $uni-border-color;
-  border-radius: 4rpx;
+  border-radius: 12rpx;
   display: flex;
   align-items: center;
   justify-content: center;
-  font-size: 36rpx;
-  font-weight: $uni-font-weight-semibold;
+  font-size: 34rpx;
+  font-weight: 600;
   color: $uni-text-color;
   background-color: $uni-bg-color-card;
-  transition: all 0.2s ease;
   box-sizing: border-box;
   text-align: center;
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
 }
 
-.plate-item.active {
+.plate-item.plate-focus {
   border-color: $uni-color-primary;
-  background-color: rgba($uni-color-primary, 0.04);
+  box-shadow: 0 0 0 4rpx rgba(198, 23, 30, 0.12);
+  z-index: 1;
 }
 
 .plate-item.new-energy {
-  width: 80rpx;
-  border-color: $uni-color-success;
-  background-color: rgba($uni-color-success, 0.06);
+  border-color: #52C41A;
+  background-color: rgba(82, 196, 26, 0.06);
+  position: relative;
+  overflow: hidden;
 }
 
-.plate-item.new-energy .placeholder {
-  color: $uni-color-success;
-  font-size: 18rpx;
+.plate-item.new-energy.plate-focus {
+  border-color: #52C41A;
+  box-shadow: 0 0 0 4rpx rgba(82, 196, 26, 0.16);
+}
+
+.energy-input {
+  width: 100%;
+  height: 100%;
   text-align: center;
-  line-height: 20rpx;
+  font-size: 34rpx;
+  font-weight: 600;
+  color: $uni-text-color;
+}
+
+.energy-icon-inside {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 48rpx;
+  height: 48rpx;
+  pointer-events: none;
+  z-index: 0;
 }
 
 .plate-region {
   background-color: $uni-bg-color-page;
-  cursor: pointer;
 }
 
 .placeholder {
   text-align: center;
+  font-size: 22rpx;
+  color: $uni-text-color-hint;
 }
 
 .plate-divider {
-  width: 24rpx;
+  width: 20rpx;
   height: 88rpx;
   display: flex;
   align-items: center;
   justify-content: center;
-  font-size: 32rpx;
-  color: $uni-text-color-secondary;
+  font-size: 28rpx;
+  color: $uni-text-color-hint;
 }
 
+// 保存按钮
 .save-btn-container {
-  margin-top: 20rpx;
+  margin-top: 8rpx;
 }
 
 .save-btn-container :deep(.uv-button) {
   height: 80rpx;
   font-size: 30rpx;
   border-radius: 40rpx;
-  background-color: $uni-color-primary;
 }
 
 .save-btn-container :deep(.uv-button--disabled) {
@@ -309,67 +400,191 @@ onShow(() => {
 }
 
 // 地区选择器
+.region-picker-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(26, 26, 26, 0.45);
+  z-index: 1000;
+}
+
 .region-picker-container {
   position: fixed;
   bottom: 0;
   left: 0;
   right: 0;
   background-color: $uni-bg-color-card;
-  border-radius: 20rpx 20rpx 0 0;
-  padding: 15rpx;
+  border-radius: 24rpx 24rpx 0 0;
+  padding: 24rpx 20rpx;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
   box-sizing: border-box;
-  box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
-  z-index: 1000;
-  height: 38vh;
-  overflow-y: hidden;
+  box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
+  height: 46vh;
+  overflow-y: auto;
 }
 
 .region-picker-header {
   display: flex;
-  justify-content: flex-end;
-  margin-bottom: 5rpx;
-  padding: 0 5rpx;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+  padding: 0 8rpx;
 }
 
-.region-picker-header :deep(.uv-button) {
+.region-picker-title {
   font-size: 30rpx;
+  font-weight: 600;
+  color: $uni-text-color;
+}
+
+.region-picker-close text {
+  font-size: 28rpx;
   color: $uni-color-primary;
-  background-color: transparent;
-  border: none;
-  padding: 5rpx 15rpx;
 }
 
 .region-grid {
   display: flex;
   flex-wrap: wrap;
-  gap: 6rpx;
-  padding: 5rpx;
-  height: calc(38vh - 60rpx);
-  overflow-y: hidden;
-  justify-content: center;
+  gap: 12rpx;
+  justify-content: flex-start;
 }
 
 .region-item {
-  width: 85rpx;
-  height: 65rpx;
+  width: 88rpx;
+  height: 72rpx;
   background-color: $uni-bg-color-page;
-  border-radius: 8rpx;
+  border-radius: 12rpx;
   display: flex;
   align-items: center;
   justify-content: center;
-  font-size: 40rpx;
-  font-weight: $uni-font-weight-semibold;
+  font-size: 36rpx;
+  font-weight: 600;
   color: $uni-text-color;
-  cursor: pointer;
-  transition: all 0.2s ease;
-  margin: 0;
+  transition: all 0.15s ease;
   flex-shrink: 0;
-  flex-basis: calc(16.66% - 8rpx);
+}
+
+.region-item.region-selected {
+  background-color: $uni-color-primary;
+  color: #FFFFFF;
 }
 
 .region-item:active {
   background-color: $uni-color-primary;
-  color: $uni-text-color-inverse;
+  color: #FFFFFF;
   transform: scale(0.95);
 }
+
+// 车辆列表
+.car-list-section {
+  background-color: $uni-bg-color-card;
+  padding: 28rpx;
+  box-sizing: border-box;
+  border-radius: 24rpx;
+}
+
+// 空状态
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 48rpx 0 32rpx;
+}
+
+.empty-icon-svg {
+  width: 104rpx;
+  height: 104rpx;
+  margin-bottom: 20rpx;
+  opacity: 0.4;
+}
+
+.empty-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $uni-text-color;
+  margin-bottom: 8rpx;
+}
+
+.empty-hint {
+  font-size: 24rpx;
+  color: $uni-text-color-hint;
+}
+
+// 车辆列表项
+.car-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx 0;
+  border-bottom: 1rpx solid $uni-border-color-light;
+
+  &:last-child, &.car-item-last {
+    border-bottom: none;
+  }
+}
+
+.car-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.car-plate-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.car-plate {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $uni-text-color;
+  letter-spacing: 1rpx;
+}
+
+.default-tag {
+  background-color: rgba(82, 196, 26, 0.1);
+  border-radius: 6rpx;
+  padding: 2rpx 10rpx;
+  flex-shrink: 0;
+}
+
+.default-tag text {
+  font-size: 20rpx;
+  font-weight: 500;
+  color: #52C41A;
+}
+
+.car-actions {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+
+.action-btn {
+  padding: 8rpx 20rpx;
+  border-radius: 32rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.set-default-btn {
+  background-color: rgba($uni-color-primary, 0.08);
+  color: $uni-color-primary;
+}
+
+.delete-btn {
+  background-color: rgba(#F44336, 0.08);
+  color: #F44336;
+}
+
+.action-btn:active {
+  opacity: 0.6;
+}
 </style>

+ 17 - 1
car-wash-mp/src/utils/https.ts

@@ -18,6 +18,7 @@ let env: string = import.meta.env.PROD ? 'prd' : 'dev'
 const apis: any = {
     dev: {
         serverUrl: "https://dev-wash.kuaiyuman.cn/api",
+        // serverUrl: "http://localhost:9099/api",
         fileUrl: "https://zyp-1258963180.cos.ap-guangzhou.myqcloud.com/"
     },
     prd: {
@@ -152,6 +153,21 @@ const request = (options: any) => {
     });
 };
 
+const del = (url: string) => {
+    let token = fetchToken() || ""
+    let options = {
+        url: fillUrl(url),
+        method: 'DELETE',
+        header: {
+            'Accept': 'application/json',
+            'Content-Type': 'application/json; charset=UTF-8',
+            'satoken': token,
+        },
+        dataType: 'json'
+    };
+    return request(options);
+};
+
 const upload = (url: string, param: any = {}) => {
     let token = fetchToken() || ""
     return new Promise((resolve, reject) => {
@@ -237,5 +253,5 @@ const formatUrl = (v: string) => {
 };
 
 export {
-    get, post, body, upload, uploadV2, cfg, serverUrl, fileUrl, formatUrl
+    get, post, body, del, upload, uploadV2, cfg, serverUrl, fileUrl, formatUrl
 }

+ 10 - 0
car-wash-service/src/main/java/com/kym/service/CarsService.java

@@ -3,6 +3,9 @@ package com.kym.service;
 import com.github.yulichang.base.MPJBaseService;
 import com.kym.entity.Cars;
 
+import java.util.List;
+import java.util.Map;
+
 /**
  * <p>
  * 车辆表 服务类
@@ -13,4 +16,11 @@ import com.kym.entity.Cars;
  */
 public interface CarsService extends MPJBaseService<Cars> {
 
+    List<Map<String, Object>> listUserCars(Long userId);
+
+    void saveCar(Long userId, String plateNo);
+
+    void setDefault(Long userId, Long carId);
+
+    void deleteCar(Long userId, Long carId);
 }

+ 95 - 0
car-wash-service/src/main/java/com/kym/service/impl/CarsServiceImpl.java

@@ -6,6 +6,10 @@ import com.kym.entity.Cars;
 import com.kym.mapper.CarsMapper;
 import com.kym.service.CarsService;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
 
 /**
  * <p>
@@ -18,4 +22,95 @@ import org.springframework.stereotype.Service;
 @Service
 public class CarsServiceImpl extends MPJBaseServiceImpl<CarsMapper, Cars> implements CarsService {
 
+    @Override
+    public List<Map<String, Object>> listUserCars(Long userId) {
+        return lambdaQuery()
+                .eq(Cars::getUserId, userId)
+                .and(w -> w.eq(Cars::getIsDelete, false).or().isNull(Cars::getIsDelete))
+                .list()
+                .stream()
+                .map(car -> Map.<String, Object>of(
+                        "id", car.getId(),
+                        "plateNo", car.getPlateNo(),
+                        "isDefault", car.getIsDefault() != null && car.getIsDefault()
+                ))
+                .toList();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void saveCar(Long userId, String plateNo) {
+        var existing = lambdaQuery()
+                .eq(Cars::getUserId, userId)
+                .eq(Cars::getPlateNo, plateNo)
+                .eq(Cars::getIsDelete, false)
+                .one();
+        if (existing != null) {
+            // already exists, set as default
+            setDefault(userId, existing.getId());
+            return;
+        }
+        long count = countUserCars(userId);
+        var car = new Cars();
+        car.setUserId(userId);
+        car.setPlateNo(plateNo);
+        car.setIsDelete(false);
+        car.setIsDefault(count == 0);
+        save(car);
+    }
+
+    private long countUserCars(Long userId) {
+        return lambdaQuery()
+                .eq(Cars::getUserId, userId)
+                .eq(Cars::getIsDelete, false)
+                .count();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void setDefault(Long userId, Long carId) {
+        // clear all defaults for this user
+        lambdaUpdate()
+                .eq(Cars::getUserId, userId)
+                .set(Cars::getIsDefault, false)
+                .update();
+        // set the target as default
+        lambdaUpdate()
+                .eq(Cars::getId, carId)
+                .eq(Cars::getUserId, userId)
+                .set(Cars::getIsDefault, true)
+                .update();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteCar(Long userId, Long carId) {
+        var car = lambdaQuery()
+                .eq(Cars::getId, carId)
+                .eq(Cars::getUserId, userId)
+                .one();
+        if (car == null) return;
+        boolean wasDefault = car.getIsDefault() != null && car.getIsDefault();
+        // soft delete
+        lambdaUpdate()
+                .eq(Cars::getId, carId)
+                .set(Cars::getIsDelete, true)
+                .set(Cars::getIsDefault, false)
+                .update();
+        // if deleted car was default, assign a new default
+        if (wasDefault) {
+            var first = lambdaQuery()
+                    .eq(Cars::getUserId, userId)
+                    .eq(Cars::getIsDelete, false)
+                    .orderByAsc(Cars::getId)
+                    .last("limit 1")
+                    .one();
+            if (first != null) {
+                lambdaUpdate()
+                        .eq(Cars::getId, first.getId())
+                        .set(Cars::getIsDefault, true)
+                        .update();
+            }
+        }
+    }
 }

+ 18 - 4
car-wash-service/src/main/java/com/kym/service/impl/UserServiceImpl.java

@@ -164,7 +164,16 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
     public UserVo getMe() {
         var userId = StpUtil.getLoginIdAsLong();
         var userVo = baseMapper.getMe(userId);
-        var car = carsService.lambdaQuery().eq(Cars::getUserId, userId).eq(Cars::getIsDefault, 1).one();
+        var cars = carsService.lambdaQuery()
+                .eq(Cars::getUserId, userId)
+                .eq(Cars::getIsDefault, 1)
+                .list();
+        var car = cars.stream().findFirst().orElse(null);
+        if (cars.size() > 1) {
+            // 清理历史脏数据:仅保留第一个默认车辆,其余清除默认标记
+            var staleDefaults = cars.stream().skip(1).peek(c -> c.setIsDefault(false)).toList();
+            carsService.updateBatchById(staleDefaults);
+        }
         if (car != null) {
             userVo.setDefaultPlateNo(car.getPlateNo());
             userVo.setVin(car.getVin());
@@ -221,14 +230,19 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
             var car = new Cars();
             car.setUserId(userVo.getId());
             car.setPlateNo(userVo.defaultPlateNo);
-            // 设置为默认
             car.setIsDefault(true);
+            car.setIsDelete(false);
             if (userVo.getVin() != null) {
                 car.setVin(userVo.getVin());
             }
             // 将用户名下其他车辆设为非默认
-            var cars = carsService.listByMap(Map.of("user_id", userVo.getId()));
-            cars.stream().filter(c -> !userVo.getDefaultPlateNo().equals(c.getEngineNo())).peek(s -> s.setIsDefault(false)).collect(Collectors.toList());
+            var cars = carsService.lambdaQuery()
+                    .eq(Cars::getUserId, userVo.getId())
+                    .and(w -> w.eq(Cars::getIsDelete, false).or().isNull(Cars::getIsDelete))
+                    .list();
+            cars.stream()
+                    .filter(c -> !userVo.getDefaultPlateNo().equals(c.getPlateNo()))
+                    .forEach(s -> s.setIsDefault(false));
             carsService.updateBatchById(cars);
             var wrapper = new QueryWrapper<Cars>();
             wrapper.eq("plate_no", userVo.getDefaultPlateNo());