Selaa lähdekoodia

feat(web): 优惠券表单加商品选择器ProductPicker,替代手输商品ID

Xiaogang Liao 3 kuukautta sitten
vanhempi
sitoutus
688c90a967

+ 261 - 0
cfc-web/src/components/ProductPicker.vue

@@ -0,0 +1,261 @@
+<template>
+  <div class="product-picker">
+    <el-select
+      :value="selectedValue"
+      :placeholder="placeholder"
+      :clearable="clearable"
+      :disabled="disabled"
+      :filterable="true"
+      :remote="true"
+      :remote-method="handleSearch"
+      :loading="loading"
+      :style="{ width: selectWidth }"
+      value-key="id"
+      class="product-picker-select"
+      @change="handleChange"
+      @clear="handleClear"
+    >
+      <el-option
+        v-for="item in options"
+        :key="item.id"
+        :label="getOptionLabel(item)"
+        :value="item.id"
+      >
+        <div class="product-picker-option">
+          <img
+            v-if="item.coverImage"
+            :src="item.coverImage"
+            class="product-picker-cover"
+            @error="handleImgError($event)"
+          />
+          <div v-else class="product-picker-cover product-picker-cover--placeholder">
+            <i class="el-icon-goods"></i>
+          </div>
+          <div class="product-picker-info">
+            <div class="product-picker-name">{{ item.name }}</div>
+            <div class="product-picker-meta">
+              <span class="product-picker-price">{{ formatPrice(item.price) }}</span>
+              <span v-if="item.productType" class="product-picker-type">{{ typeLabel(item.productType) }}</span>
+              <span class="product-picker-id">ID: {{ item.id }}</span>
+            </div>
+          </div>
+        </div>
+      </el-option>
+      <div v-if="loading" class="product-picker-empty">加载中...</div>
+      <div v-else-if="searched && options.length === 0" class="product-picker-empty">未找到匹配的商品</div>
+    </el-select>
+  </div>
+</template>
+
+<script>
+import { getProductList, getProduct } from '@/api/admin'
+
+export default {
+  name: 'ProductPicker',
+  props: {
+    // v-model 绑定的商品ID
+    value: {
+      type: [Number, String],
+      default: null
+    },
+    placeholder: {
+      type: String,
+      default: '请输入商品名称搜索'
+    },
+    clearable: {
+      type: Boolean,
+      default: true
+    },
+    disabled: {
+      type: Boolean,
+      default: false
+    },
+    selectWidth: {
+      type: String,
+      default: '320px'
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      options: [],
+      selectedValue: null,
+      selectedItem: null,
+      searched: false,
+      page: 1,
+      pageSize: 20,
+      hasMore: false,
+      keyword: ''
+    }
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        // 处理数字/字符串类型
+        if (val !== null && val !== undefined && val !== '') {
+          const num = Number(val)
+          if (!isNaN(num)) {
+            this.selectedValue = num
+            // 如果已有选中项且ID匹配,无需重新拉取
+            if (this.selectedItem && this.selectedItem.id === num) return
+            this.fetchProductById(num)
+          } else {
+            this.selectedValue = null
+            this.selectedItem = null
+          }
+        } else {
+          this.selectedValue = null
+          this.selectedItem = null
+        }
+      }
+    }
+  },
+  methods: {
+    typeLabel(val) {
+      const map = { physical: '实物商品', assessment: '测评服务' }
+      return map[val] || val || ''
+    },
+    formatPrice(price) {
+      if (price === null || price === undefined) return ''
+      return (price / 100).toFixed(2) + '元'
+    },
+    getOptionLabel(item) {
+      // 选中后显示在输入框中的文本
+      return `${item.name} (¥${(item.price / 100).toFixed(2)})`
+    },
+    async fetchProductById(id) {
+      try {
+        const res = await getProduct(id)
+        if (res && res.data && res.data.id) {
+          this.selectedItem = res.data
+          // 把当前选中项放入选项列表(确保显示)
+          const exists = this.options.some(o => o.id === res.data.id)
+          if (!exists) {
+            this.options = [res.data, ...this.options]
+          }
+        }
+      } catch (e) {
+        console.error('[ProductPicker] 加载商品详情失败', e)
+        // 编辑回显失败不影响使用,允许用户重新搜索
+      }
+    },
+    async handleSearch(query) {
+      this.keyword = (query || '').trim()
+      this.page = 1
+      this.searched = true
+      await this.loadOptions(true)
+    },
+    async loadOptions(reset) {
+      this.loading = true
+      try {
+        const res = await getProductList({
+          keyword: this.keyword || undefined,
+          page: this.page,
+          size: this.pageSize
+        })
+        const data = (res && res.data) || {}
+        const records = data.records || []
+        if (reset) {
+          this.options = records
+        } else {
+          this.options = this.options.concat(records)
+        }
+        // 如果有当前选中项但不在列表中,补回
+        if (this.selectedItem && !this.options.some(o => o.id === this.selectedItem.id)) {
+          this.options = [this.selectedItem, ...this.options]
+        }
+        const total = data.total || records.length
+        this.hasMore = this.options.length < total
+      } catch (e) {
+        console.error('[ProductPicker] 搜索商品失败', e)
+        if (reset) this.options = []
+      } finally {
+        this.loading = false
+      }
+    },
+    handleChange(val) {
+      this.selectedValue = val
+      if (val !== null && val !== undefined && val !== '') {
+        const found = this.options.find(o => o.id === Number(val))
+        this.selectedItem = found || null
+      } else {
+        this.selectedItem = null
+      }
+      this.$emit('input', val === null || val === undefined ? null : Number(val))
+      this.$emit('change', this.selectedItem)
+    },
+    handleClear() {
+      this.selectedItem = null
+      this.$emit('input', null)
+      this.$emit('change', null)
+    },
+    handleImgError(e) {
+      // 封面图加载失败时用占位符替换
+      e.target.style.display = 'none'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.product-picker-option {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 4px 0;
+}
+.product-picker-cover {
+  width: 40px;
+  height: 40px;
+  border-radius: 4px;
+  object-fit: cover;
+  flex-shrink: 0;
+  background: #f5f7fa;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.product-picker-cover--placeholder {
+  color: #c0c4cc;
+  font-size: 18px;
+}
+.product-picker-info {
+  flex: 1;
+  min-width: 0;
+}
+.product-picker-name {
+  font-size: 14px;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.product-picker-meta {
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-top: 2px;
+}
+.product-picker-price {
+  color: #f56c6c;
+  font-weight: 600;
+}
+.product-picker-type {
+  background: #ecf5ff;
+  color: #409eff;
+  padding: 1px 6px;
+  border-radius: 2px;
+}
+.product-picker-id {
+  color: #c0c4cc;
+}
+.product-picker-empty {
+  padding: 10px 0;
+  text-align: center;
+  color: #909399;
+  font-size: 13px;
+}
+</style>

+ 5 - 3
cfc-web/src/views/admin/CouponManagement.vue

@@ -109,9 +109,9 @@
             <el-option label="测评服务" value="ASSESSMENT" />
             <el-option label="测评服务" value="ASSESSMENT" />
           </el-select>
           </el-select>
         </el-form-item>
         </el-form-item>
-        <el-form-item label="绑定商品ID">
-          <el-input-number v-model="form.productId" :min="0" :step="1" style="width: 200px" />
-          <div class="form-item-tip">0 或留空 = 全场通用</div>
+        <el-form-item label="绑定商品">
+          <product-picker v-model="form.productId" placeholder="输入商品名称搜索,留空=全场通用" />
+          <div class="form-item-tip">留空 = 全场通用;仅适用对象选"特定商品"时必填</div>
         </el-form-item>
         </el-form-item>
         <el-form-item label="积分兑换价">
         <el-form-item label="积分兑换价">
           <el-input-number v-model="form.pointsPrice" :min="0" :step="100" style="width: 200px" />
           <el-input-number v-model="form.pointsPrice" :min="0" :step="100" style="width: 200px" />
@@ -189,9 +189,11 @@
 
 
 <script>
 <script>
 import { getCouponList, createCoupon, updateCoupon, deleteCoupon, batchIssueCoupons } from '@/api/coupon'
 import { getCouponList, createCoupon, updateCoupon, deleteCoupon, batchIssueCoupons } from '@/api/coupon'
+import ProductPicker from '@/components/ProductPicker'
 
 
 export default {
 export default {
   name: 'CouponManagement',
   name: 'CouponManagement',
+  components: { ProductPicker },
   computed: {
   computed: {
     tableHeight() {
     tableHeight() {
       return window.innerHeight - 300
       return window.innerHeight - 300