فهرست منبع

feat: 商品管理前端 — 列表/分类/编辑/SKU组件 + DimensionWeightPicker修复

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 2 ماه پیش
والد
کامیت
a0136608c6

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

@@ -439,6 +439,114 @@ export function shelveProduct(productId, data) {
   })
   })
 }
 }
 
 
+export function getProduct(id) {
+  return request({
+    url: '/api/admin/product/detail',
+    method: 'post',
+    data: { productId: id }
+  })
+}
+
+export function createProduct(data) {
+  return request({
+    url: '/api/admin/product/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updateProduct(data) {
+  return request({
+    url: '/api/admin/product/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteProduct(id) {
+  return request({
+    url: '/api/admin/product/delete',
+    method: 'post',
+    data: { productId: id }
+  })
+}
+
+// ========== SKU 管理 API ==========
+
+export function getProductSkuList(productId) {
+  return request({
+    url: '/api/admin/product/sku/list',
+    method: 'post',
+    data: { productId }
+  })
+}
+
+export function createProductSku(data) {
+  return request({
+    url: '/api/admin/product/sku/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updateProductSku(data) {
+  return request({
+    url: '/api/admin/product/sku/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteProductSku(id) {
+  return request({
+    url: '/api/admin/product/sku/delete',
+    method: 'post',
+    data: { id }
+  })
+}
+
+// ========== 分类管理 API ==========
+
+export function getCategoryTree(includeDisabled) {
+  return request({
+    url: '/api/admin/shop/category/tree',
+    method: 'post',
+    data: { includeDisabled }
+  })
+}
+
+export function createCategory(data) {
+  return request({
+    url: '/api/admin/shop/category/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updateCategory(data) {
+  return request({
+    url: '/api/admin/shop/category/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteCategory(id) {
+  return request({
+    url: '/api/admin/shop/category/delete',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function toggleCategory(id) {
+  return request({
+    url: '/api/admin/shop/category/toggle',
+    method: 'post',
+    data: { id }
+  })
+}
+
 // ========== 订单管理 API ==========
 // ========== 订单管理 API ==========
 
 
 export function getOrderList(params) {
 export function getOrderList(params) {

+ 4 - 0
cfc-web/src/components/DimensionWeightPicker.vue

@@ -152,6 +152,10 @@ export default {
 }
 }
 .dim-slider {
 .dim-slider {
   flex: 1;
   flex: 1;
+  height: 38px;
+}
+.dim-slider /deep/ .el-slider__runway {
+  height: 6px;
 }
 }
 .dim-input {
 .dim-input {
   width: 72px;
   width: 72px;

+ 12 - 0
cfc-web/src/router/index.js

@@ -210,6 +210,18 @@ const routes = [
         component: () => import('@/views/admin/ProductManage.vue'),
         component: () => import('@/views/admin/ProductManage.vue'),
         meta: { title: '商品管理', perm: 'commerce:products' }
         meta: { title: '商品管理', perm: 'commerce:products' }
       },
       },
+      {
+        path: 'product-edit',
+        name: 'ProductEdit',
+        component: () => import('@/views/admin/ProductEdit.vue'),
+        meta: { title: '商品编辑', perm: 'commerce:products' }
+      },
+      {
+        path: 'category-manage',
+        name: 'CategoryManage',
+        component: () => import('@/views/admin/CategoryManage.vue'),
+        meta: { title: '商品分类', perm: 'commerce:category' }
+      },
       {
       {
         path: 'order-manage',
         path: 'order-manage',
         name: 'OrderManage',
         name: 'OrderManage',

+ 275 - 0
cfc-web/src/views/admin/CategoryManage.vue

@@ -0,0 +1,275 @@
+<template>
+  <div class="category-manage">
+    <el-card>
+      <div slot="header">
+        <span>商品分类管理</span>
+        <el-button style="float: right;" type="primary" size="mini" icon="el-icon-plus" @click="handleCreate">新增分类</el-button>
+      </div>
+
+      <div class="category-layout">
+        <div class="category-tree-panel">
+          <el-tree
+            :data="treeData"
+            :props="{ label: 'name', children: 'children' }"
+            node-key="id"
+            default-expand-all
+            highlight-current
+            @node-click="handleNodeClick"
+          >
+            <span slot-scope="{ node, data }" class="tree-node">
+              <span>{{ node.label }}</span>
+              <span class="node-actions">
+                <el-switch
+                  :value="data.enabled"
+                  size="mini"
+                  @click.stop
+                  @change="handleToggle(data)"
+                  :active-text="data.enabled ? '' : ''"
+                  :inactive-text="''"
+                />
+              </span>
+            </span>
+          </el-tree>
+        </div>
+
+        <div class="category-form-panel">
+          <el-form ref="form" :model="form" :rules="rules" label-width="90px" size="small">
+            <el-form-item label="分类名称" prop="name">
+              <el-input v-model="form.name" placeholder="请输入分类名称" maxlength="50" />
+            </el-form-item>
+
+            <el-form-item label="上级分类">
+              <el-select v-model="form.parentId" placeholder="顶级分类(无上级)" clearable style="width: 100%;">
+                <el-option label="顶级分类" :value="0" />
+                <el-option
+                  v-for="cat in flatCategories"
+                  :key="cat.id"
+                  :label="cat.name"
+                  :value="cat.id"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item label="排序">
+              <el-input-number v-model="form.sort" :min="0" :max="9999" style="width: 100%;" />
+            </el-form-item>
+
+            <el-form-item label="关联维度">
+              <el-checkbox-group v-model="selectedDimensions">
+                <el-checkbox label="body">身</el-checkbox>
+                <el-checkbox label="mind">智</el-checkbox>
+                <el-checkbox label="wealth">富</el-checkbox>
+                <el-checkbox label="action">行</el-checkbox>
+                <el-checkbox label="heart">心</el-checkbox>
+              </el-checkbox-group>
+            </el-form-item>
+
+            <el-form-item label="商品类型">
+              <el-checkbox-group v-model="selectedProductTypes">
+                <el-checkbox label="physical">实物商品</el-checkbox>
+                <el-checkbox label="virtual">虚拟商品</el-checkbox>
+                <el-checkbox label="coupon">优惠券</el-checkbox>
+              </el-checkbox-group>
+            </el-form-item>
+
+            <el-form-item label="启用状态">
+              <el-switch v-model="form.enabled" :active-value="true" :inactive-value="false" />
+            </el-form-item>
+
+            <el-form-item>
+              <el-button type="primary" :loading="saving" @click="handleSave">
+                {{ form.id ? '保存修改' : '创建分类' }}
+              </el-button>
+              <el-button v-if="form.id" type="danger" :loading="deleting" @click="handleDelete">删除</el-button>
+              <el-button v-if="form.id" @click="handleReset">新增</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getCategoryTree, createCategory, updateCategory, deleteCategory, toggleCategory } from '@/api/admin'
+
+export default {
+  name: 'CategoryManage',
+  data() {
+    return {
+      treeData: [],
+      flatCategories: [],
+      form: {
+        id: null,
+        name: '',
+        parentId: 0,
+        sort: 0,
+        enabled: true,
+        dimensionCodes: '',
+        productTypes: ''
+      },
+      selectedDimensions: [],
+      selectedProductTypes: [],
+      rules: {
+        name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
+      },
+      saving: false,
+      deleting: false
+    }
+  },
+  mounted() {
+    this.loadTree()
+  },
+  methods: {
+    async loadTree() {
+      try {
+        const res = await getCategoryTree(true)
+        if (res.code === 200) {
+          this.treeData = res.data || []
+          this.flatCategories = this.flattenTree(res.data || [])
+        }
+      } catch (e) {
+        this.$message.error('加载分类失败')
+      }
+    },
+    flattenTree(nodes, result) {
+      result = result || []
+      for (const node of nodes) {
+        result.push({ id: node.id, name: node.name, parentId: node.parentId })
+        if (node.children && node.children.length > 0) {
+          this.flattenTree(node.children, result)
+        }
+      }
+      return result
+    },
+    handleNodeClick(data) {
+      this.form = {
+        id: data.id,
+        name: data.name,
+        parentId: data.parentId || 0,
+        sort: data.sort || 0,
+        enabled: data.enabled !== false,
+        dimensionCodes: data.dimensionCodes || '',
+        productTypes: data.productTypes || ''
+      }
+      this.selectedDimensions = data.dimensionCodes ? data.dimensionCodes.split(',').filter(Boolean) : []
+      this.selectedProductTypes = data.productTypes ? data.productTypes.split(',').filter(Boolean) : []
+    },
+    handleReset() {
+      this.form = {
+        id: null,
+        name: '',
+        parentId: 0,
+        sort: 0,
+        enabled: true,
+        dimensionCodes: '',
+        productTypes: ''
+      }
+      this.selectedDimensions = []
+      this.selectedProductTypes = []
+      this.$refs.form && this.$refs.form.resetFields()
+    },
+    handleCreate() {
+      this.handleReset()
+    },
+    async handleSave() {
+      this.$refs.form.validate(async (valid) => {
+        if (!valid) return
+        this.saving = true
+        try {
+          const payload = {
+            ...this.form,
+            dimensionCodes: this.selectedDimensions.join(','),
+            productTypes: this.selectedProductTypes.join(',')
+          }
+          let res
+          if (payload.id) {
+            res = await updateCategory(payload)
+          } else {
+            res = await createCategory(payload)
+          }
+          if (res.code === 200) {
+            this.$message.success(payload.id ? '更新成功' : '创建成功')
+            this.loadTree()
+            if (!payload.id) {
+              this.handleReset()
+            }
+          } else {
+            this.$message.error(res.message || '操作失败')
+          }
+        } catch (e) {
+          this.$message.error('操作失败')
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+    async handleDelete() {
+      if (!this.form.id) return
+      try {
+        await this.$confirm('确认删除该分类?', '提示')
+        this.deleting = true
+        const res = await deleteCategory(this.form.id)
+        if (res.code === 200) {
+          this.$message.success('删除成功')
+          this.loadTree()
+          this.handleReset()
+        } else {
+          this.$message.error(res.message || '删除失败')
+        }
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('删除失败')
+      } finally {
+        this.deleting = false
+      }
+    },
+    async handleToggle(data) {
+      try {
+        const res = await toggleCategory(data.id)
+        if (res.code === 200) {
+          this.$message.success(res.data || '切换成功')
+          this.loadTree()
+        } else {
+          this.$message.error(res.message || '切换失败')
+        }
+      } catch (e) {
+        this.$message.error('切换失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.category-manage {
+  padding: 20px;
+}
+.category-layout {
+  display: flex;
+  gap: 20px;
+  min-height: 400px;
+}
+.category-tree-panel {
+  width: 300px;
+  flex-shrink: 0;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  padding: 10px;
+  overflow: auto;
+}
+.category-form-panel {
+  flex: 1;
+  max-width: 500px;
+}
+.tree-node {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  padding-right: 8px;
+}
+.node-actions {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 369 - 0
cfc-web/src/views/admin/ProductEdit.vue

@@ -0,0 +1,369 @@
+<template>
+  <div class="product-edit">
+    <el-card>
+      <div slot="header">
+        <span>{{ isEdit ? '编辑商品' : '新增商品' }}</span>
+        <el-button style="float: right;" size="mini" @click="$router.back()">返回</el-button>
+      </div>
+
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="商品名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入商品名称" maxlength="100" />
+        </el-form-item>
+
+        <el-form-item label="商品类型" prop="productType">
+          <el-select v-model="form.productType" placeholder="请选择商品类型" style="width: 200px;">
+            <el-option label="实物商品" value="physical" />
+            <el-option label="虚拟商品" value="virtual" />
+            <el-option label="优惠券" value="coupon" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="商品简介" prop="intro">
+          <el-input v-model="form.intro" type="textarea" :rows="2" placeholder="简短描述" maxlength="200" />
+        </el-form-item>
+
+        <el-form-item label="商品描述" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="4" placeholder="详细描述" />
+        </el-form-item>
+
+        <el-form-item label="封面图片" prop="coverImage">
+          <div class="cover-uploader">
+            <el-upload
+              :action="uploadActionUrl"
+              :headers="uploadHeaders"
+              :show-file-list="false"
+              :on-success="handleCoverSuccess"
+              :before-upload="beforeUpload"
+              accept="image/*"
+            >
+              <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
+              <i v-else class="el-icon-plus cover-icon"></i>
+            </el-upload>
+            <span class="cover-tip">点击上传封面图,建议尺寸 800x800</span>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="商品图片">
+          <div class="images-uploader">
+            <div v-for="(img, idx) in imageList" :key="idx" class="image-item">
+              <img :src="img" class="image-preview" />
+              <span class="image-remove" @click="removeImage(idx)">×</span>
+            </div>
+            <el-upload
+              :action="uploadActionUrl"
+              :headers="uploadHeaders"
+              :show-file-list="false"
+              :on-success="handleImageSuccess"
+              :before-upload="beforeUpload"
+              accept="image/*"
+            >
+              <i class="el-icon-plus image-add-icon"></i>
+            </el-upload>
+          </div>
+        </el-form-item>
+
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="售价(元)" prop="price">
+              <el-input-number v-model="form.price" :min="0" :precision="2" placeholder="0.00" style="width: 100%;" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="会员价(元)">
+              <el-input-number v-model="form.memberPrice" :min="0" :precision="2" placeholder="0.00" style="width: 100%;" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="库存" prop="stock">
+              <el-input-number v-model="form.stock" :min="0" :precision="0" placeholder="0" style="width: 100%;" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="供应商">
+          <el-input v-model="form.vendorName" placeholder="供应商名称(管理员创建时填写)" />
+        </el-form-item>
+
+        <el-form-item label="配送方式">
+          <el-radio-group v-model="form.deliveryMethod">
+            <el-radio :label="1">快递</el-radio>
+            <el-radio :label="2">自提</el-radio>
+            <el-radio :label="3">两者皆可</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="会员专属">
+          <el-radio-group v-model="form.memberEligible">
+            <el-radio label="no">否</el-radio>
+            <el-radio label="yes">是</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="商品维度">
+          <el-select v-model="form.domain" placeholder="选择关联维度" clearable style="width: 200px;">
+            <el-option label="身" value="body" />
+            <el-option label="智" value="mind" />
+            <el-option label="富" value="wealth" />
+            <el-option label="行" value="action" />
+            <el-option label="心" value="heart" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item v-if="isEdit" label="SKU规格">
+          <ProductSkuEditor :productId="form.id" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" :loading="saving" @click="handleSave">
+            {{ isEdit ? '保存修改' : '创建商品' }}
+          </el-button>
+          <el-button @click="$router.back()">取消</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getProduct, createProduct, updateProduct } from '@/api/admin'
+import ProductSkuEditor from './components/ProductSkuEditor.vue'
+
+export default {
+  name: 'ProductEdit',
+  components: { ProductSkuEditor },
+  data() {
+    return {
+      form: {
+        name: '',
+        intro: '',
+        description: '',
+        coverImage: '',
+        images: '',
+        productType: 'physical',
+        price: 0,
+        memberPrice: 0,
+        stock: 0,
+        vendorName: '',
+        vendorId: null,
+        deliveryMethod: 1,
+        memberEligible: 'no',
+        domain: ''
+      },
+      imageList: [],
+      rules: {
+        name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
+        productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
+        price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
+        stock: [{ required: true, message: '请输入库存', trigger: 'blur' }]
+      },
+      saving: false
+    }
+  },
+  computed: {
+    isEdit() {
+      return !!this.$route.query.id
+    },
+    uploadActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || 'http://localhost:9082'
+      return baseURL + '/api/admin/articles/upload/image'
+    },
+    uploadHeaders() {
+      const token = localStorage.getItem('token')
+      return { Authorization: 'Bearer ' + token }
+    }
+  },
+  mounted() {
+    if (this.isEdit) {
+      this.loadProduct(this.$route.query.id)
+    }
+  },
+  methods: {
+    async loadProduct(id) {
+      try {
+        const res = await getProduct(id)
+        if (res.code === 200 && res.data) {
+          const p = res.data
+          this.form = {
+            id: p.id,
+            name: p.name || '',
+            intro: p.intro || '',
+            description: p.description || '',
+            coverImage: p.coverImage || '',
+            images: p.images || '',
+            productType: p.productType || 'physical',
+            price: p.price ? p.price / 100 : 0,
+            memberPrice: p.memberPrice ? p.memberPrice / 100 : 0,
+            stock: p.stock || 0,
+            vendorName: p.vendorName || '',
+            vendorId: p.vendorId || null,
+            deliveryMethod: p.deliveryMethod || 1,
+            memberEligible: p.memberEligible || 'no',
+            domain: p.domain || ''
+          }
+          if (p.images) {
+            try {
+              this.imageList = JSON.parse(p.images)
+            } catch (e) {
+              this.imageList = []
+            }
+          }
+        }
+      } catch (e) {
+        this.$message.error('加载商品失败')
+      }
+    },
+    beforeUpload(file) {
+      const isImage = file.type.startsWith('image/')
+      const isLt5M = file.size / 1024 / 1024 < 5
+      if (!isImage) {
+        this.$message.error('只能上传图片文件')
+        return false
+      }
+      if (!isLt5M) {
+        this.$message.error('图片大小不能超过 5MB')
+        return false
+      }
+      return true
+    },
+    handleCoverSuccess(res) {
+      if (res.code === 200 && res.data) {
+        this.form.coverImage = res.data.url || res.data
+        this.$message.success('封面上传成功')
+      } else {
+        this.$message.error(res.message || '上传失败')
+      }
+    },
+    handleImageSuccess(res) {
+      if (res.code === 200 && res.data) {
+        const url = res.data.url || res.data
+        this.imageList.push(url)
+        this.form.images = JSON.stringify(this.imageList)
+        this.$message.success('图片上传成功')
+      } else {
+        this.$message.error(res.message || '上传失败')
+      }
+    },
+    removeImage(idx) {
+      this.imageList.splice(idx, 1)
+      this.form.images = JSON.stringify(this.imageList)
+    },
+    async handleSave() {
+      this.$refs.form.validate(async (valid) => {
+        if (!valid) return
+        this.saving = true
+        try {
+          const payload = {
+            ...this.form,
+            price: Math.round(this.form.price * 100),
+            memberPrice: this.form.memberPrice ? Math.round(this.form.memberPrice * 100) : null,
+            images: JSON.stringify(this.imageList)
+          }
+          let res
+          if (this.isEdit) {
+            res = await updateProduct(payload)
+          } else {
+            res = await createProduct(payload)
+          }
+          if (res.code === 200) {
+            this.$message.success(this.isEdit ? '保存成功' : '创建成功')
+            this.$router.push('/product-manage')
+          } else {
+            this.$message.error(res.message || '操作失败')
+          }
+        } catch (e) {
+          this.$message.error('操作失败')
+        } finally {
+          this.saving = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.product-edit {
+  padding: 20px;
+  max-width: 800px;
+}
+.cover-uploader {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.cover-preview {
+  width: 100px;
+  height: 100px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #ddd;
+}
+.cover-icon {
+  width: 100px;
+  height: 100px;
+  line-height: 100px;
+  text-align: center;
+  font-size: 28px;
+  color: #8c939d;
+  border: 1px dashed #d9d9d9;
+  border-radius: 4px;
+  display: block;
+  cursor: pointer;
+}
+.cover-icon:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+.cover-tip {
+  font-size: 12px;
+  color: #999;
+}
+.images-uploader {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.image-item {
+  position: relative;
+  width: 80px;
+  height: 80px;
+}
+.image-preview {
+  width: 80px;
+  height: 80px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #ddd;
+}
+.image-remove {
+  position: absolute;
+  top: -6px;
+  right: -6px;
+  width: 18px;
+  height: 18px;
+  background: #f56c6c;
+  color: #fff;
+  border-radius: 50%;
+  text-align: center;
+  line-height: 18px;
+  font-size: 12px;
+  cursor: pointer;
+}
+.image-add-icon {
+  width: 80px;
+  height: 80px;
+  line-height: 80px;
+  text-align: center;
+  font-size: 24px;
+  color: #8c939d;
+  border: 1px dashed #d9d9d9;
+  border-radius: 4px;
+  display: block;
+  cursor: pointer;
+}
+.image-add-icon:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+</style>

+ 108 - 52
cfc-web/src/views/admin/ProductManage.vue

@@ -3,19 +3,31 @@
     <div class="header">
     <div class="header">
       <h2>商品管理</h2>
       <h2>商品管理</h2>
       <div class="filters">
       <div class="filters">
-        <el-select v-model="statusFilter" placeholder="商品状态" @change="loadList" style="width: 140px;">
+        <el-input
+          v-model="filters.keyword"
+          placeholder="搜索商品名称"
+          prefix-icon="el-icon-search"
+          clearable
+          style="width: 180px;"
+          @clear="loadList"
+          @keyup.enter.native="loadList"
+        />
+        <el-select v-model="filters.status" placeholder="商品状态" @change="loadList" style="width: 130px;" clearable>
           <el-option label="全部" value="" />
           <el-option label="全部" value="" />
           <el-option label="待审核" value="pending" />
           <el-option label="待审核" value="pending" />
-          <el-option label="已上架" value="approved" />
+          <el-option label="待上架" value="approved" />
+          <el-option label="已上架" value="on_shelf" />
+          <el-option label="已下架" value="off_shelf" />
           <el-option label="已拒绝" value="rejected" />
           <el-option label="已拒绝" value="rejected" />
-          <el-option label="已下架" value="shelved" />
         </el-select>
         </el-select>
-        <el-select v-model="typeFilter" placeholder="商品类型" @change="loadList" style="width: 140px;">
+        <el-select v-model="filters.productType" placeholder="商品类型" @change="loadList" style="width: 140px;" clearable>
           <el-option label="全部类型" value="" />
           <el-option label="全部类型" value="" />
           <el-option label="实物商品" value="physical" />
           <el-option label="实物商品" value="physical" />
           <el-option label="虚拟商品" value="virtual" />
           <el-option label="虚拟商品" value="virtual" />
           <el-option label="优惠券" value="coupon" />
           <el-option label="优惠券" value="coupon" />
         </el-select>
         </el-select>
+        <el-button type="primary" size="mini" @click="loadList">查询</el-button>
+        <el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/product-edit')">新增商品</el-button>
       </div>
       </div>
     </div>
     </div>
 
 
@@ -23,8 +35,13 @@
       <el-table-column prop="id" label="ID" width="70" />
       <el-table-column prop="id" label="ID" width="70" />
       <el-table-column label="商品图片" width="80">
       <el-table-column label="商品图片" width="80">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
-          <img v-if="row.coverImage" :src="row.coverImage" class="product-thumb" @click="previewImage(row.coverImage)" />
-          <span v-else>-</span>
+          <img
+            v-if="row.coverImage"
+            :src="row.coverImage"
+            class="product-thumb"
+            @click="previewImage(row.coverImage)"
+          />
+          <span v-else style="color:#999">-</span>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
       <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
@@ -38,44 +55,52 @@
           {{ formatPriceWithSymbol(row.price || 0) }}
           {{ formatPriceWithSymbol(row.price || 0) }}
         </template>
         </template>
       </el-table-column>
       </el-table-column>
-      <el-table-column prop="stock" label="库存" width="70" />
+      <el-table-column prop="stock" label="库存" width="80">
+        <template slot-scope="{ row }">
+          <span :class="{ 'stock-warning': row.stock !== null && row.stock < 10 }">
+            {{ row.stock }}
+          </span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="salesCount" label="销量" width="70" />
       <el-table-column label="供应商" width="120" show-overflow-tooltip>
       <el-table-column label="供应商" width="120" show-overflow-tooltip>
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
-          {{ row.vendorName || row.brandName || '-' }}
+          {{ row.vendorName || '-' }}
         </template>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column label="状态" width="90">
       <el-table-column label="状态" width="90">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
-          <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+          <el-tag :type="statusType(row.status)" size="mini">{{ statusLabel(row.status) }}</el-tag>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
-      <el-table-column prop="createTime" label="创建时间" width="160">
+      <el-table-column prop="createdAt" label="创建时间" width="160">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
-          {{ formatTime(row.createTime || row.createdAt) }}
+          {{ formatTime(row.createdAt) }}
         </template>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column label="操作" width="220" fixed="right">
       <el-table-column label="操作" width="220" fixed="right">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
           <el-button v-if="row.status === 'pending'" type="success" size="small" @click="handleReview(row, 'approve')">通过</el-button>
           <el-button v-if="row.status === 'pending'" type="success" size="small" @click="handleReview(row, 'approve')">通过</el-button>
           <el-button v-if="row.status === 'pending'" type="danger" size="small" @click="showRejectDialog(row)">拒绝</el-button>
           <el-button v-if="row.status === 'pending'" type="danger" size="small" @click="showRejectDialog(row)">拒绝</el-button>
-          <el-button v-if="row.status === 'approved'" type="warning" size="small" @click="handleShelve(row, false)">下架</el-button>
-          <el-button v-if="row.status === 'shelved'" type="primary" size="small" @click="handleShelve(row, true)">上架</el-button>
-          <el-button type="info" size="small" @click="viewDetail(row)">详情</el-button>
+          <el-button v-if="row.status === 'on_shelf'" type="warning" size="small" @click="handleShelve(row, false)">下架</el-button>
+          <el-button v-if="row.status === 'off_shelf'" type="primary" size="small" @click="handleShelve(row, true)">上架</el-button>
+          <el-button type="info" size="small" @click="$router.push('/product-edit?id=' + row.id)">编辑</el-button>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
     </el-table>
     </el-table>
 
 
     <el-pagination
     <el-pagination
-      @size-change="onPageChange"
-      @current-change="onPageChange"
-      :current-page="page"
-      :page-size="size"
-      :total="total"
-      layout="total, prev, pager, next"
-      style="margin-top: 20px; text-align: right;"
+      @size-change="handleSizeChange"
+      @current-change="handlePageChange"
+      :current-page="pagination.page"
+      :page-sizes="[10, 20, 50]"
+      :page-size="pagination.size"
+      :total="pagination.total"
+      layout="total, sizes, prev, pager, next, jumper"
+      style="margin-top: 16px; text-align: right;"
     />
     />
 
 
-    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
+    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="420px">
       <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
       <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
       <span slot="footer">
       <span slot="footer">
         <el-button @click="rejectDialogVisible = false">取消</el-button>
         <el-button @click="rejectDialogVisible = false">取消</el-button>
@@ -91,15 +116,15 @@
         <el-descriptions-item label="会员价">{{ detail.memberPrice ? formatPriceWithSymbol(detail.memberPrice) : '-' }}</el-descriptions-item>
         <el-descriptions-item label="会员价">{{ detail.memberPrice ? formatPriceWithSymbol(detail.memberPrice) : '-' }}</el-descriptions-item>
         <el-descriptions-item label="库存">{{ detail.stock }}</el-descriptions-item>
         <el-descriptions-item label="库存">{{ detail.stock }}</el-descriptions-item>
         <el-descriptions-item label="销量">{{ detail.salesCount || 0 }}</el-descriptions-item>
         <el-descriptions-item label="销量">{{ detail.salesCount || 0 }}</el-descriptions-item>
-        <el-descriptions-item label="供应商">{{ detail.vendorName || detail.brandName || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="供应商手机">{{ detail.vendorPhone || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="供应商">{{ detail.vendorName || '-' }}</el-descriptions-item>
         <el-descriptions-item label="状态">
         <el-descriptions-item label="状态">
-          <el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
+          <el-tag :type="statusType(detail.status)" size="mini">{{ statusLabel(detail.status) }}</el-tag>
         </el-descriptions-item>
         </el-descriptions-item>
         <el-descriptions-item label="拒绝原因" v-if="detail.rejectReason">{{ detail.rejectReason }}</el-descriptions-item>
         <el-descriptions-item label="拒绝原因" v-if="detail.rejectReason">{{ detail.rejectReason }}</el-descriptions-item>
         <el-descriptions-item label="商品描述" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
         <el-descriptions-item label="商品描述" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="创建时间" :span="2">{{ formatTime(detail.createTime || detail.createdAt) }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间" :span="2">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
       </el-descriptions>
       </el-descriptions>
+      <div v-else style="text-align:center;color:#999;">加载中...</div>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
 </template>
 </template>
@@ -107,19 +132,22 @@
 <script>
 <script>
 import { getProductList, reviewProduct, shelveProduct } from '@/api/admin'
 import { getProductList, reviewProduct, shelveProduct } from '@/api/admin'
 
 
-
 export default {
 export default {
+  name: 'ProductManage',
   data() {
   data() {
     return {
     return {
-
       list: [],
       list: [],
       loading: false,
       loading: false,
-      page: 1,
-      size: 20,
-      total: 0,
-      statusFilter: '',
-      typeFilter: '',
-
+      filters: {
+        keyword: '',
+        status: '',
+        productType: ''
+      },
+      pagination: {
+        page: 1,
+        size: 20,
+        total: 0
+      },
       rejectDialogVisible: false,
       rejectDialogVisible: false,
       rejectTarget: null,
       rejectTarget: null,
       rejectReason: '',
       rejectReason: '',
@@ -134,37 +162,63 @@ export default {
     async loadList() {
     async loadList() {
       this.loading = true
       this.loading = true
       try {
       try {
-        var res = await getProductList({ status: this.statusFilter })
-        this.list = res.data || []
-        this.total = (res.data || []).length
+        const params = {
+          page: this.pagination.page,
+          size: this.pagination.size,
+          status: this.filters.status || null,
+          productType: this.filters.productType || null,
+          keyword: this.filters.keyword || null
+        }
+        const res = await getProductList(params)
+        this.list = res.data.records || []
+        this.pagination.total = res.data.total || 0
       } catch (e) {
       } catch (e) {
         this.$message.error('加载失败')
         this.$message.error('加载失败')
       } finally {
       } finally {
         this.loading = false
         this.loading = false
       }
       }
     },
     },
-    onPageChange(val) {
-      this.page = val
+    handleSizeChange(size) {
+      this.pagination.size = size
+      this.pagination.page = 1
+      this.loadList()
+    },
+    handlePageChange(page) {
+      this.pagination.page = page
       this.loadList()
       this.loadList()
     },
     },
     statusType(status) {
     statusType(status) {
-      var s = String(status)
-      var map = { pending: 'warning', '0': 'warning', approved: 'success', '1': 'success', rejected: 'danger', shelved: 'info' }
-      return map[s] || 'info'
+      const map = {
+        pending: 'warning',
+        approved: 'success',
+        on_shelf: 'success',
+        off_shelf: 'info',
+        rejected: 'danger'
+      }
+      return map[status] || 'info'
     },
     },
     statusLabel(status) {
     statusLabel(status) {
-      var s = String(status)
-      var map = { pending: '待审核', '0': '待审核', approved: '已上架', '1': '已上架', rejected: '已拒绝', shelved: '已下架' }
-      return map[s] || status
+      const map = {
+        pending: '待审核',
+        approved: '待上架',
+        on_shelf: '已上架',
+        off_shelf: '已下架',
+        rejected: '已拒绝'
+      }
+      return map[status] || status
     },
     },
     typeLabel(type) {
     typeLabel(type) {
-      var map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券' }
+      const map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券' }
       return map[type] || type
       return map[type] || type
     },
     },
     formatTime(time) {
     formatTime(time) {
       if (!time) return '-'
       if (!time) return '-'
       return new Date(time).toLocaleString()
       return new Date(time).toLocaleString()
     },
     },
+    formatPriceWithSymbol(price) {
+      if (price === 0) return '免费'
+      return '¥' + (price / 100).toFixed(2)
+    },
     async handleReview(row, action) {
     async handleReview(row, action) {
       try {
       try {
         await this.$confirm('确认通过该商品审核?', '提示')
         await this.$confirm('确认通过该商品审核?', '提示')
@@ -203,10 +257,6 @@ export default {
         if (e !== 'cancel') this.$message.error('操作失败')
         if (e !== 'cancel') this.$message.error('操作失败')
       }
       }
     },
     },
-    viewDetail(row) {
-      this.detail = row
-      this.detailDialogVisible = true
-    },
     previewImage(url) {
     previewImage(url) {
       if (!url) return
       if (!url) return
       this.$alert('<img src="' + url + '" style="max-width:100%;" />', '商品图片', {
       this.$alert('<img src="' + url + '" style="max-width:100%;" />', '商品图片', {
@@ -228,13 +278,15 @@ export default {
   align-items: center;
   align-items: center;
   margin-bottom: 20px;
   margin-bottom: 20px;
   flex-wrap: wrap;
   flex-wrap: wrap;
+  gap: 12px;
 }
 }
 .header h2 {
 .header h2 {
   margin: 0;
   margin: 0;
+  font-size: 18px;
 }
 }
 .filters {
 .filters {
   display: flex;
   display: flex;
-  gap: 10px;
+  gap: 8px;
   flex-wrap: wrap;
   flex-wrap: wrap;
   align-items: center;
   align-items: center;
 }
 }
@@ -245,4 +297,8 @@ export default {
   border-radius: 4px;
   border-radius: 4px;
   cursor: pointer;
   cursor: pointer;
 }
 }
-</style>
+.stock-warning {
+  color: #f56c6c;
+  font-weight: bold;
+}
+</style>

+ 295 - 0
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="sku-editor">
+    <div class="sku-header">
+      <span class="sku-title">SKU 规格</span>
+      <el-button type="primary" size="mini" icon="el-icon-plus" @click="showDialog(null)">添加SKU</el-button>
+    </div>
+
+    <el-table :data="skus" border stripe size="small">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column label="规格" min-width="120">
+        <template slot-scope="{ row }">
+          <span>{{ parseSpecs(row.specs) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="售价(元)" width="100">
+        <template slot-scope="{ row }">
+          {{ formatPrice(row.price) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="原价(元)" width="100">
+        <template slot-scope="{ row }">
+          {{ row.originalPrice ? formatPrice(row.originalPrice) : '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="stock" label="库存" width="80" />
+      <el-table-column label="规格图片" width="80">
+        <template slot-scope="{ row }">
+          <img v-if="row.image" :src="row.image" style="width:40px;height:40px;object-fit:cover;border-radius:4px;" />
+          <span v-else style="color:#999">-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="启用" width="70">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.enabled ? 'success' : 'info'" size="mini">
+            {{ row.enabled ? '是' : '否' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="120" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" @click="showDialog(row)">编辑</el-button>
+          <el-button type="danger" size="mini" @click="handleDelete(row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @close="resetForm">
+      <el-form ref="skuForm" :model="skuForm" :rules="skuRules" label-width="80px">
+        <el-form-item label="规格" prop="specs">
+          <el-input v-model="skuForm.specs" placeholder="例如:颜色:红色;尺码:M" />
+        </el-form-item>
+        <el-form-item label="售价(元)" prop="price">
+          <el-input-number v-model="skuForm.price" :min="0" :precision="2" style="width: 100%;" />
+        </el-form-item>
+        <el-form-item label="原价(元)">
+          <el-input-number v-model="skuForm.originalPrice" :min="0" :precision="2" style="width: 100%;" />
+        </el-form-item>
+        <el-form-item label="库存" prop="stock">
+          <el-input-number v-model="skuForm.stock" :min="0" :precision="0" style="width: 100%;" />
+        </el-form-item>
+        <el-form-item label="规格图片">
+          <div class="sku-image-row">
+            <el-upload
+              :action="uploadActionUrl"
+              :headers="uploadHeaders"
+              :show-file-list="false"
+              :on-success="handleImageSuccess"
+              :before-upload="beforeUpload"
+              accept="image/*"
+            >
+              <img v-if="skuForm.image" :src="skuForm.image" class="sku-img-preview" />
+              <el-button v-else size="small" type="primary">上传图片</el-button>
+            </el-upload>
+          </div>
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="skuForm.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getProductSkuList, createProductSku, updateProductSku, deleteProductSku } from '@/api/admin'
+
+export default {
+  name: 'ProductSkuEditor',
+  props: {
+    productId: {
+      type: Number,
+      default: null
+    }
+  },
+  data() {
+    return {
+      skus: [],
+      loading: false,
+      dialogVisible: false,
+      saving: false,
+      editingId: null,
+      skuForm: {
+        specs: '',
+        price: 0,
+        originalPrice: 0,
+        stock: 0,
+        image: '',
+        enabled: 1
+      },
+      skuRules: {
+        specs: [{ required: true, message: '请输入规格', trigger: 'blur' }],
+        price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
+        stock: [{ required: true, message: '请输入库存', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.editingId ? '编辑SKU' : '添加SKU'
+    },
+    uploadActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || 'http://localhost:9082'
+      return baseURL + '/api/admin/articles/upload/image'
+    },
+    uploadHeaders() {
+      const token = localStorage.getItem('token')
+      return { Authorization: 'Bearer ' + token }
+    }
+  },
+  watch: {
+    productId: {
+      immediate: true,
+      handler(val) {
+        if (val) {
+          this.loadSkus()
+        }
+      }
+    }
+  },
+  methods: {
+    async loadSkus() {
+      if (!this.productId) return
+      this.loading = true
+      try {
+        const res = await getProductSkuList(this.productId)
+        if (res.code === 200) {
+          this.skus = res.data || []
+        }
+      } catch (e) {
+        this.$message.error('加载SKU失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    showDialog(sku) {
+      if (sku) {
+        this.editingId = sku.id
+        this.skuForm = {
+          specs: sku.specs || '',
+          price: sku.price ? parseFloat(sku.price) : 0,
+          originalPrice: sku.originalPrice ? parseFloat(sku.originalPrice) : 0,
+          stock: sku.stock || 0,
+          image: sku.image || '',
+          enabled: sku.enabled !== undefined ? sku.enabled : 1
+        }
+      } else {
+        this.editingId = null
+        this.resetForm()
+      }
+      this.dialogVisible = true
+    },
+    resetForm() {
+      this.skuForm = {
+        specs: '',
+        price: 0,
+        originalPrice: 0,
+        stock: 0,
+        image: '',
+        enabled: 1
+      }
+      this.$refs.skuForm && this.$refs.skuForm.resetFields()
+    },
+    beforeUpload(file) {
+      const isImage = file.type.startsWith('image/')
+      const isLt5M = file.size / 1024 / 1024 < 5
+      if (!isImage) { this.$message.error('只能上传图片'); return false }
+      if (!isLt5M) { this.$message.error('图片大小不能超过5MB'); return false }
+      return true
+    },
+    handleImageSuccess(res) {
+      if (res.code === 200) {
+        this.skuForm.image = res.data.url || res.data
+        this.$message.success('上传成功')
+      } else {
+        this.$message.error(res.message || '上传失败')
+      }
+    },
+    parseSpecs(specs) {
+      if (!specs) return '-'
+      try {
+        const obj = JSON.parse(specs)
+        return Object.entries(obj).map(([k, v]) => k + ':' + v).join('; ')
+      } catch (e) {
+        return specs
+      }
+    },
+    formatPrice(val) {
+      if (!val) return '¥0.00'
+      return '¥' + parseFloat(val).toFixed(2)
+    },
+    async handleSave() {
+      this.$refs.skuForm.validate(async (valid) => {
+        if (!valid) return
+        this.saving = true
+        try {
+          const payload = {
+            productId: this.productId,
+            specs: this.skuForm.specs,
+            price: parseFloat(this.skuForm.price),
+            originalPrice: this.skuForm.originalPrice ? parseFloat(this.skuForm.originalPrice) : null,
+            stock: this.skuForm.stock,
+            image: this.skuForm.image,
+            enabled: this.skuForm.enabled
+          }
+          let res
+          if (this.editingId) {
+            payload.id = this.editingId
+            res = await updateProductSku(payload)
+          } else {
+            res = await createProductSku(payload)
+          }
+          if (res.code === 200) {
+            this.$message.success('保存成功')
+            this.dialogVisible = false
+            this.loadSkus()
+          } else {
+            this.$message.error(res.message || '保存失败')
+          }
+        } catch (e) {
+          this.$message.error('保存失败')
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+    async handleDelete(id) {
+      try {
+        await this.$confirm('确认删除该SKU?', '提示')
+        const res = await deleteProductSku(id)
+        if (res.code === 200) {
+          this.$message.success('删除成功')
+          this.loadSkus()
+        } else {
+          this.$message.error(res.message || '删除失败')
+        }
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('删除失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.sku-editor {
+  margin-top: 16px;
+}
+.sku-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+.sku-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+.sku-image-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+.sku-img-preview {
+  width: 60px;
+  height: 60px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #ddd;
+}
+</style>