Переглянути джерело

feat(admin): ProductSkuEditor 双模式(规格变体/关联商品)+ ProductPicker 组件

asus 1 місяць тому
батько
коміт
0f403d358e

+ 8 - 0
cfc-web/src/api/admin.js

@@ -628,6 +628,14 @@ export function updateProductSku(data) {
   })
 }
 
+export function linkedProductList(params) {
+  return request({
+    url: '/api/admin/product/linked/list',
+    method: 'post',
+    data: params
+  })
+}
+
 export function deleteProductSku(id) {
   return request({
     url: '/api/admin/product/sku/delete',

+ 113 - 0
cfc-web/src/views/admin/components/ProductPicker.vue

@@ -0,0 +1,113 @@
+<template>
+  <el-dialog :visible.sync="visible" title="选择关联商品" width="600px" @close="handleClose">
+    <el-form :inline="true" size="small">
+      <el-form-item label="搜索">
+        <el-input v-model="query.keyword" placeholder="商品名称" clearable @keyup.enter.native="search" style="width:200px;" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="search">搜索</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="list" highlight-current-row @current-change="onSelect" size="small">
+      <el-table-column width="50">
+        <template slot-scope="{ row }">
+          <el-radio :value="selectedId" :label="row.id" @change="onSelect(row)">&nbsp;</el-radio>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品" min-width="300">
+        <template slot-scope="{ row }">
+          <div style="display:flex;align-items:center;gap:10px;">
+            <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
+            <span v-else style="width:50px;height:50px;background:#f0f0f0;display:inline-block;border-radius:4px;"></span>
+            <div>
+              <div>{{ row.name }}</div>
+              <div style="font-size:12px;color:#999;">¥{{ row.price ? (row.price/100).toFixed(2) : '0.00' }}</div>
+            </div>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column prop="stock" label="库存" width="80" />
+    </el-table>
+
+    <el-pagination
+      v-if="total > pageSize"
+      :current-page="query.page"
+      :page-size="pageSize"
+      :total="total"
+      layout="prev, pager, next"
+      @current-change="onPageChange"
+      style="margin-top:10px;text-align:right;"
+    />
+
+    <span slot="footer">
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" :disabled="!selectedProduct" @click="confirm">确认选择</el-button>
+    </span>
+  </el-dialog>
+</template>
+
+<script>
+import { linkedProductList } from '@/api/admin'
+
+export default {
+  name: 'ProductPicker',
+  props: {
+    productId: { type: Number, default: null }
+  },
+  data() {
+    return {
+      visible: false,
+      list: [],
+      total: 0,
+      pageSize: 20,
+      query: { keyword: '', page: 1 },
+      selectedProduct: null
+    }
+  },
+  computed: {
+    selectedId() { return this.selectedProduct ? this.selectedProduct.id : null }
+  },
+  methods: {
+    open() {
+      this.visible = true
+      this.selectedProduct = null
+      this.search()
+    },
+    async search() {
+      this.query.page = 1
+      await this.load()
+    },
+    async load() {
+      try {
+        const res = await linkedProductList({
+          productId: this.productId,
+          keyword: this.query.keyword,
+          page: this.query.page,
+          pageSize: this.pageSize
+        })
+        if (res.code === 200) {
+          this.list = res.data.list || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) {
+        this.$message.error('加载商品列表失败')
+      }
+    },
+    onSelect(row) {
+      if (row) this.selectedProduct = row
+    },
+    confirm() {
+      this.$emit('selected', this.selectedProduct)
+      this.visible = false
+    },
+    onPageChange(page) {
+      this.query.page = page
+      this.load()
+    },
+    handleClose() {
+      this.selectedProduct = null
+    }
+  }
+}
+</script>

+ 133 - 9
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -46,6 +46,31 @@
 
     <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @close="resetForm">
       <el-form ref="skuForm" :model="skuForm" :rules="skuRules" label-width="80px">
+        <el-radio-group v-model="isLinkedMode" size="small" style="margin-bottom:16px;">
+          <el-radio-button :label="false">规格变体</el-radio-button>
+          <el-radio-button :label="true">关联商品</el-radio-button>
+        </el-radio-group>
+
+        <!-- 关联商品模式 -->
+        <template v-if="isLinkedMode">
+          <el-form-item label="显示标签">
+            <el-input v-model="skuForm.label" placeholder="默认使用关联商品名称" />
+          </el-form-item>
+          <el-form-item label="关联商品">
+            <el-button size="small" @click="openProductPicker">选择商品</el-button>
+            <div v-if="skuForm.linkedProductId" style="margin-top:8px;display:flex;align-items:center;gap:10px;padding:8px;background:#f5f7fa;border-radius:4px;">
+              <img v-if="skuForm.linkedProductImage" :src="skuForm.linkedProductImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
+              <div>
+                <div>{{ skuForm.linkedProductName }}</div>
+                <div style="font-size:12px;color:#999;">¥{{ skuForm.linkedProductPrice ? (skuForm.linkedProductPrice/100).toFixed(2) : '0.00' }} | 库存 {{ skuForm.linkedProductStock }}</div>
+              </div>
+              <el-button size="mini" type="text" @click="openProductPicker">更换</el-button>
+            </div>
+          </el-form-item>
+        </template>
+
+        <!-- 规格变体模式(原有) -->
+        <template v-else>
         <el-form-item label="规格" prop="specs">
           <el-input v-model="skuForm.specs" placeholder="例如:颜色:红色;尺码:M" />
         </el-form-item>
@@ -73,6 +98,8 @@
             </el-upload>
           </div>
         </el-form-item>
+        </template>
+
         <el-form-item label="启用">
           <el-switch v-model="skuForm.enabled" :active-value="1" :inactive-value="0" />
         </el-form-item>
@@ -82,14 +109,18 @@
         <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
       </span>
     </el-dialog>
+
+    <ProductPicker ref="productPicker" :product-id="productId" @selected="onProductSelected" />
   </div>
 </template>
 
 <script>
 import { getProductSkuList, createProductSku, updateProductSku, deleteProductSku } from '@/api/admin'
+import ProductPicker from './ProductPicker.vue'
 
 export default {
   name: 'ProductSkuEditor',
+  components: { ProductPicker },
   props: {
     productId: {
       type: Number,
@@ -104,13 +135,20 @@ export default {
       dialogVisible: false,
       saving: false,
       editingId: null,
+      isLinkedMode: false,
       skuForm: {
         specs: '',
         price: 0,
         originalPrice: 0,
         stock: 0,
         image: '',
-        enabled: 1
+        enabled: 1,
+        label: '',
+        linkedProductId: null,
+        linkedProductName: '',
+        linkedProductPrice: 0,
+        linkedProductImage: '',
+        linkedProductStock: 0
       },
       skuRules: {
         specs: [{ required: true, message: '请输入规格', trigger: 'blur' }],
@@ -170,13 +208,37 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
     showDialog(sku) {
       if (sku) {
         this.editingId = sku.id
-        this.skuForm = {
-          specs: this.formatSpecsForInput(sku.specs),
-          price: sku.price ? (sku.price / 100) : 0,
-          originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
-          stock: sku.stock || 0,
-          image: sku.image || '',
-          enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1
+        this.isLinkedMode = sku.linkedProductId != null
+        if (this.isLinkedMode) {
+          this.skuForm = {
+            label: sku.label || '',
+            linkedProductId: sku.linkedProductId,
+            linkedProductName: sku.linkedProductName || '',
+            linkedProductPrice: sku.linkedProductPrice || 0,
+            linkedProductImage: sku.linkedProductImage || '',
+            linkedProductStock: sku.linkedProductStock || 0,
+            enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
+            specs: '',
+            price: 0,
+            originalPrice: 0,
+            stock: 0,
+            image: ''
+          }
+        } else {
+          this.skuForm = {
+            specs: this.formatSpecsForInput(sku.specs),
+            price: sku.price ? (sku.price / 100) : 0,
+            originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
+            stock: sku.stock || 0,
+            image: sku.image || '',
+            enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
+            label: '',
+            linkedProductId: null,
+            linkedProductName: '',
+            linkedProductPrice: 0,
+            linkedProductImage: '',
+            linkedProductStock: 0
+          }
         }
       } else {
         this.editingId = null
@@ -185,13 +247,20 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
       this.dialogVisible = true
     },
     resetForm() {
+      this.isLinkedMode = false
       this.skuForm = {
         specs: '',
         price: 0,
         originalPrice: 0,
         stock: 0,
         image: '',
-        enabled: 1
+        enabled: 1,
+        label: '',
+        linkedProductId: null,
+        linkedProductName: '',
+        linkedProductPrice: 0,
+        linkedProductImage: '',
+        linkedProductStock: 0
       }
       this.$refs.skuForm && this.$refs.skuForm.resetFields()
     },
@@ -290,7 +359,62 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
       if (!val) return '¥0.00'
       return '¥' + (parseFloat(val) / 100).toFixed(2)
     },
+    openProductPicker() {
+      this.$refs.productPicker.open()
+    },
+    onProductSelected(product) {
+      this.skuForm.linkedProductId = product.id
+      this.skuForm.linkedProductName = product.name
+      this.skuForm.linkedProductPrice = product.price
+      this.skuForm.linkedProductImage = product.coverImage || product.image || ''
+      this.skuForm.linkedProductStock = product.stock
+    },
     async handleSave() {
+      // Linked SKU 模式不校验 specs/price/stock
+      if (this.isLinkedMode) {
+        if (!this.skuForm.linkedProductId) {
+          this.$message.warning('请选择关联商品')
+          return
+        }
+        this.saving = true
+        try {
+          const payload = {
+            productId: this.productId,
+            linkedProductId: this.skuForm.linkedProductId,
+            label: this.skuForm.label || null,
+            enabled: this.skuForm.enabled
+          }
+          if (!this.productId) {
+            payload.id = Date.now()
+            delete payload.productId
+            this.pendingSkus.push(payload)
+            this.dialogVisible = false
+            this.$message.success('添加成功,保存商品时将自动创建SKU')
+            this.saving = false
+            return
+          }
+          let res
+          if (this.editingId) {
+            payload.id = this.editingId
+            delete payload.productId
+            res = await updateProductSku(payload)
+          } else {
+            res = await createProductSku(payload)
+          }
+          if (res.code === 200) {
+            await this.loadSkus()
+            this.$message.success('保存成功')
+            this.dialogVisible = false
+          } else {
+            this.$message.error(res.message || '保存失败')
+          }
+        } catch (e) {
+          this.$message.error('保存失败')
+        } finally {
+          this.saving = false
+        }
+        return
+      }
       this.$refs.skuForm.validate(async (valid) => {
         if (!valid) return
         this.saving = true