ソースを参照

Merge branch 'dev'

skyline 3 日 前
コミット
6717e282e0

+ 36 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/DevicePriceVo.java

@@ -0,0 +1,36 @@
+package com.kym.entity.vo;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 设备价格公示 VO — 面向用户展示的洗车服务单价
+ *
+ * @author skyline
+ * @since 2026-06-20
+ */
+@Data
+@Accessors(chain = true)
+public class DevicePriceVo {
+
+    /** 场地费(分/分钟) */
+    private Integer priceSpace;
+    /** 清水(分/分钟) */
+    private Integer priceWater;
+    /** 泡沫(分/分钟) */
+    private Integer priceFoam;
+    /** 吸尘(分/分钟) */
+    private Integer priceCleaner;
+    /** 洗手(分/分钟) */
+    private Integer priceTap;
+    /** 扩展项目(分/分钟) */
+    private Integer priceUserExt;
+    /** 镀膜(分/分钟) */
+    private Integer priceCoat;
+    /** 吹气(分/分钟) */
+    private Integer priceBlow;
+    /** 最低消费(分) */
+    private Integer amountMinLimit;
+    /** 最高消费/预扣金额(分) */
+    private Integer prepayMoney;
+}

+ 5 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/WashDeviceVo.java

@@ -106,4 +106,9 @@ public class WashDeviceVo extends BaseEntity {
      */
     private Integer sequence;
 
+    /**
+     * 价格公示信息
+     */
+    private DevicePriceVo price;
+
 }

+ 194 - 0
car-wash-mp/src/components/price-table/index.vue

@@ -0,0 +1,194 @@
+<template>
+  <view class="price-card" v-if="hasAnyPrice">
+    <view class="card-header">
+      <view class="header-row">
+        <view class="header-dot"></view>
+        <text class="header-title">收费标准<text class="header-tip">(以场地设备实际功能为准)</text></text>
+        <view class="header-close" @click="emit('close')">
+          <text class="close-icon">✕</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="price-grid">
+      <view class="price-item" v-for="item in priceItems" :key="item.label">
+        <text class="price-label">{{ item.label }}</text>
+        <text class="price-value">{{ fmtYuanPerMin(item.value) }}</text>
+      </view>
+    </view>
+
+    <view class="price-footer" v-if="amountMinLimit != null || prepayMoney != null">
+      <view class="footer-item" v-if="amountMinLimit != null && amountMinLimit > 0">
+        <text class="footer-label">最低消费</text>
+        <text class="footer-value">¥{{ (amountMinLimit / 100).toFixed(2) }}</text>
+      </view>
+      <view class="footer-item" v-if="prepayMoney != null && prepayMoney > 0">
+        <text class="footer-label">单次上限</text>
+        <text class="footer-value">¥{{ (prepayMoney / 100).toFixed(2) }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts" name="PriceTable">
+import { computed } from "vue";
+
+const PRICE_LABELS: Record<string, string> = {
+  priceSpace: "场地费",
+  priceWater: "清水",
+  priceFoam: "泡沫",
+  priceCleaner: "吸尘",
+  priceTap: "洗手",
+  priceUserExt: "扩展项目",
+  priceCoat: "镀膜",
+  priceBlow: "吹气",
+};
+
+const props = defineProps({
+  price: {
+    type: Object,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["close"]);
+
+const amountMinLimit = computed(() => props.price?.amountMinLimit);
+const prepayMoney = computed(() => props.price?.prepayMoney);
+
+const hasAnyPrice = computed(() => {
+  if (!props.price) return false;
+  return Object.keys(PRICE_LABELS).some((key) => props.price[key] != null);
+});
+
+const priceItems = computed(() => {
+  if (!props.price) return [];
+  return Object.entries(PRICE_LABELS)
+    .filter(([key]) => props.price[key] != null)
+    .map(([key, label]) => ({ label, value: props.price[key] }));
+});
+
+const fmtYuanPerMin = (fen: number) => {
+  if (fen == null) return "";
+  const yuan = fen / 100;
+  return yuan % 1 === 0 ? `¥${yuan}/分钟` : `¥${yuan.toFixed(2)}/分钟`;
+};
+</script>
+
+<style lang="scss" scoped>
+.price-card {
+  width: 580rpx;
+  max-height: 80vh;
+  background: $uni-bg-color-card;
+  border-radius: 24rpx;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  .card-header {
+    padding: 32rpx 32rpx 0;
+
+    .header-row {
+      display: flex;
+      align-items: center;
+      gap: 12rpx;
+    }
+
+    .header-dot {
+      width: 10rpx;
+      height: 10rpx;
+      background: $uni-color-primary;
+      border-radius: 50%;
+      flex-shrink: 0;
+    }
+
+    .header-title {
+      font-size: 30rpx;
+      font-weight: $uni-font-weight-semibold;
+      color: $uni-text-color-dark;
+      flex: 1;
+    }
+
+    .header-close {
+      width: 48rpx;
+      height: 48rpx;
+      border-radius: 50%;
+      background: $uni-bg-color-page;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+
+      .close-icon {
+        font-size: 24rpx;
+        color: $uni-text-color-tertiary;
+      }
+    }
+
+    .header-tip {
+      font-size: 22rpx;
+      color: $uni-text-color-tertiary;
+      font-weight: $uni-font-weight-normal;
+    }
+  }
+
+  .price-grid {
+    display: flex;
+    flex-wrap: wrap;
+    padding: 24rpx 32rpx 12rpx;
+    overflow-y: auto;
+
+    .price-item {
+      width: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 14rpx 0;
+      box-sizing: border-box;
+
+      &:nth-child(odd) {
+        padding-right: 20rpx;
+      }
+
+      &:nth-child(even) {
+        padding-left: 20rpx;
+      }
+
+      .price-label {
+        font-size: 26rpx;
+        color: $uni-text-color-hint;
+      }
+
+      .price-value {
+        font-size: 26rpx;
+        color: $uni-color-primary;
+        font-weight: $uni-font-weight-semibold;
+      }
+    }
+  }
+
+  .price-footer {
+    display: flex;
+    gap: 32rpx;
+    padding: 16rpx 32rpx 32rpx;
+    border-top: 1rpx solid $uni-border-color-light;
+
+    .footer-item {
+      display: flex;
+      align-items: center;
+      gap: 8rpx;
+
+      .footer-label {
+        font-size: 24rpx;
+        color: $uni-text-color-tertiary;
+      }
+
+      .footer-value {
+        font-size: 24rpx;
+        color: $uni-text-color-dark;
+        font-weight: $uni-font-weight-medium;
+      }
+    }
+  }
+}
+</style>

+ 53 - 0
car-wash-mp/src/pages-wash/device/index.vue

@@ -26,6 +26,20 @@
         </view>
       </view>
 
+      <!-- 价格公示入口 -->
+      <view class="price-entry" v-if="state.device?.price" @click="pricePopupRef?.open()">
+        <view class="price-entry-left">
+          <text class="price-icon">¥</text>
+          <text class="price-text">收费标准</text>
+        </view>
+        <uv-icon name="arrow-right" size="16" color="#C0C4CC"></uv-icon>
+      </view>
+
+      <!-- 价格公示弹窗 -->
+      <uv-popup ref="pricePopupRef" mode="center" :round="0" :closeable="false" customStyle="background:transparent;">
+        <PriceTable :price="state.device?.price" @close="pricePopupRef?.close()" />
+      </uv-popup>
+
       <!-- 停车费提示 -->
       <view class="parking-notice" v-if="state.parkingFee">
         <view class="parking-notice-left">
@@ -117,6 +131,7 @@ import {get, post} from "@/utils/https";
 import {checkLogin, fetchToken, tryLogin} from "@/utils/auth";
 import {fmtMoney} from "@/utils/common";
 import LoginBar from "@/components/login-bar/index.vue";
+import PriceTable from "@/components/price-table/index.vue";
 
 const guideSteps = [
   '点击上方【启动设备】按钮启动设备',
@@ -144,6 +159,7 @@ const initState = () => ({
 
 const state = reactive(initState())
 const timerId = ref<ReturnType<typeof setInterval> | null>(null)
+const pricePopupRef = ref()
 
 const isDeviceOperable = computed(() => {
   const s = state.device?.state
@@ -491,6 +507,43 @@ const handleGotoRechage = () => {
   }
 }
 
+// 价格公示入口
+.price-entry {
+  margin: 0 30rpx 24rpx;
+  padding: 20rpx 28rpx;
+  background: rgba($uni-color-primary, 0.05);
+  border: 1rpx solid rgba($uni-color-primary, 0.12);
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  &-left {
+    display: flex;
+    align-items: center;
+    gap: 12rpx;
+    flex: 1;
+  }
+
+  .price-icon {
+    width: 36rpx;
+    height: 36rpx;
+    line-height: 36rpx;
+    text-align: center;
+    font-size: 20rpx;
+    font-weight: $uni-font-weight-bold;
+    color: #fff;
+    background: $uni-color-primary;
+    border-radius: 6rpx;
+    flex-shrink: 0;
+  }
+
+  .price-text {
+    font-size: 26rpx;
+    color: $uni-text-color-secondary;
+  }
+}
+
 // 停车费提示
 .parking-notice {
   margin: 0 30rpx 24rpx;

+ 62 - 0
car-wash-mp/src/pages-wash/station/index.vue

@@ -25,6 +25,20 @@
         <WashStation :item="state.station" ref="station_ref" :showPhone="true"></WashStation>
       </view>
 
+      <!-- 价格公示入口 -->
+      <view class="price-entry" v-if="unifiedPrice" @click="pricePopupRef?.open()">
+        <view class="price-entry-left">
+          <text class="price-icon">¥</text>
+          <text class="price-text">收费标准</text>
+        </view>
+        <uv-icon name="arrow-right" size="16" color="#C0C4CC"></uv-icon>
+      </view>
+
+      <!-- 价格公示弹窗 -->
+      <uv-popup ref="pricePopupRef" mode="center" :round="0" :closeable="false" customStyle="background:transparent;">
+        <PriceTable :price="unifiedPrice" @close="pricePopupRef?.close()" />
+      </uv-popup>
+
       <!-- 设备列表 -->
       <view class="device-section">
         <view class="section-header">
@@ -117,9 +131,11 @@ import { onLoad } from "@dcloudio/uni-app";
 import { get } from "@/utils/https";
 import { fmtDictName } from "@/utils/common";
 import WashStation from "@/components/station/index.vue";
+import PriceTable from "@/components/price-table/index.vue";
 
 const station_ref = ref();
 const deviceLoading = ref(false);
+const pricePopupRef = ref();
 
 const initState = () => ({
   deviceList: [] as any[],
@@ -136,6 +152,15 @@ const swiperImages = computed(() => {
   return pictures.split("|").filter(Boolean);
 });
 
+// 站点统一价格(去重后若全部设备共用一个配置则返回该价格,否则返回 null)
+const unifiedPrice = computed(() => {
+  const devices = state.deviceList;
+  if (!devices.length) return null;
+  const configIds = new Set(devices.map((d: any) => d.deviceConfigId).filter(Boolean));
+  if (configIds.size !== 1) return null;
+  return devices[0].price || null;
+});
+
 onLoad((options: any) => {
   const id = options?.id;
   const cached = getApp<any>().globalData.last?.station;
@@ -244,6 +269,43 @@ const getDeviceStatusClass = (stateVal: string) => {
   margin: 0 30rpx 24rpx;
 }
 
+// 价格公示入口
+.price-entry {
+  margin: 0 30rpx 24rpx;
+  padding: 20rpx 28rpx;
+  background: rgba($uni-color-primary, 0.05);
+  border: 1rpx solid rgba($uni-color-primary, 0.12);
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  &-left {
+    display: flex;
+    align-items: center;
+    gap: 12rpx;
+    flex: 1;
+  }
+
+  .price-icon {
+    width: 36rpx;
+    height: 36rpx;
+    line-height: 36rpx;
+    text-align: center;
+    font-size: 20rpx;
+    font-weight: $uni-font-weight-bold;
+    color: #fff;
+    background: $uni-color-primary;
+    border-radius: 6rpx;
+    flex-shrink: 0;
+  }
+
+  .price-text {
+    font-size: 26rpx;
+    color: $uni-text-color-secondary;
+  }
+}
+
 // 设备区域
 .device-section {
   margin: 0 30rpx;

+ 45 - 0
car-wash-service/src/main/java/com/kym/service/impl/WashDeviceServiceImpl.java

@@ -9,6 +9,7 @@ import com.kym.entity.WashDevice;
 import com.kym.entity.WashOrder;
 import com.kym.entity.common.PageBean;
 import com.kym.entity.queryParams.DeviceQueryParams;
+import com.kym.entity.vo.DevicePriceVo;
 import com.kym.entity.vo.WashDeviceVo;
 import com.kym.mapper.WashDeviceMapper;
 import com.kym.service.DeviceConfigService;
@@ -70,6 +71,13 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
                 .orderByAsc(WashDevice::getState)
                 .orderByAsc(WashDevice::getDeviceName)
                 .list();
+
+        if (list.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        var configMap = loadDeviceConfigMap(list);
+
         var voList = new ArrayList<WashDeviceVo>();
         //TODO 根据订单查询占用中的设备
 //        var currentUserId = StpUtil.getLoginIdAsLong();
@@ -78,6 +86,7 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
 //            .setCurrentUserId(currentUserId);
             BeanUtils.copyProperties(washDevice, vo);
             vo.setShortId(KymCache.INSTANCE.getShortIdByProductKeyAndDeviceName(washDevice.getProductKey(), washDevice.getDeviceName()));
+            vo.setPrice(toDevicePriceVo(configMap.get(washDevice.getDeviceConfigId())));
             voList.add(vo);
         }
         return voList;
@@ -109,6 +118,11 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
 
             var vo = new WashDeviceVo().setCurrentUserId(StpUtil.getLoginIdAsLong());
             BeanUtils.copyProperties(washDevice, vo.setShortId(shortId));
+
+            if (washDevice != null && washDevice.getDeviceConfigId() != null) {
+                var config = deviceConfigService.getById(washDevice.getDeviceConfigId());
+                vo.setPrice(toDevicePriceVo(config));
+            }
             return vo;
         } else {
             throw new BusinessException("设备他人使用中!");
@@ -248,4 +262,35 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
 
     //endregion
 
+    //region 价格公示
+
+    private java.util.Map<Long, DeviceConfig> loadDeviceConfigMap(List<WashDevice> devices) {
+        var configIds = devices.stream().map(WashDevice::getDeviceConfigId).filter(id -> id != null).toList();
+        if (configIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return deviceConfigService.lambdaQuery()
+                .in(DeviceConfig::getId, configIds)
+                .list().stream().collect(Collectors.toMap(DeviceConfig::getId, Function.identity()));
+    }
+
+    private DevicePriceVo toDevicePriceVo(DeviceConfig config) {
+        if (config == null) {
+            return null;
+        }
+        return new DevicePriceVo()
+                .setPriceSpace(config.getPriceSpace())
+                .setPriceWater(config.getPriceWater())
+                .setPriceFoam(config.getPriceFoam())
+                .setPriceCleaner(config.getPriceCleaner())
+                .setPriceTap(config.getPriceTap())
+                .setPriceUserExt(config.getPriceUserExt())
+                .setPriceCoat(config.getPriceCoat())
+                .setPriceBlow(config.getPriceBlow())
+                .setAmountMinLimit(config.getAmountMinLimit())
+                .setPrepayMoney(config.getPrepayMoney());
+    }
+
+    //endregion
+
 }