| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- <template>
- <div class="product-edit admin-page">
- <el-card v-loading="pageLoading">
- <div slot="header" class="admin-page-header">
- <span class="admin-page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
- <div class="admin-page-actions">
- <el-button size="mini" @click="$router.back()">返回</el-button>
- </div>
- </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-option label="测评商品" value="assessment" />
- </el-select>
- </el-form-item>
- <!-- 测评商品扩展信息 -->
- <template v-if="form.productType === 'assessment'">
- <el-form-item label="测评类型">
- <el-select v-model="form.assessmentType" placeholder="请选择测评类型" style="width: 200px;">
- <el-option label="单包" value="single" />
- <el-option label="套餐" value="bundle" />
- </el-select>
- </el-form-item>
- <el-row :gutter="20">
- <el-col :span="8">
- <el-form-item label="测评次数">
- <el-input-number v-model="form.totalSessions" :min="1" :max="999" :precision="0" style="width: 100%;" controls />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="有效期(天)">
- <el-input-number v-model="form.validityDays" :min="1" :max="3650" :precision="0" :default-value="365" style="width: 100%;" controls />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="规划师范围">
- <el-select v-model="form.guideScope" placeholder="不限" style="width: 100%;">
- <el-option label="不限" value="all" />
- <el-option label="指定规划师" value="assigned" />
- <el-option label="自动分配" value="auto" />
- </el-select>
- </el-form-item>
- </el-col>
- </el-row>
- </template>
- <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">
- <div class="rich-editor">
- <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
- <Editor :defaultConfig="editorConfig" v-model="form.description" @onCreated="handleEditorCreated" style="min-height: 300px;" />
- </div>
- </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%;" controls />
- </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%;" controls />
- </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%;" controls />
- </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-select v-model="form.distributionSystemId" placeholder="不属于任何体系" clearable style="width: 100%" @change="handleSystemChange">
- <el-option v-for="s in supplySystems" :key="s.id" :label="s.name" :value="s.id"></el-option>
- </el-select>
- <span v-if="selectedSystem" style="font-size: 12px; color: #999; margin-left: 8px">
- 平台留利: {{ selectedSystem.platformProfitRate }}% → {{ selectedSystem.platformProfitRate * 10 }}‰
- </span>
- </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="商品维度">
- <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
- </el-form-item>
- <el-form-item v-if="isEdit" label="SKU规格">
- <ProductSkuEditor :productId="form.id" />
- </el-form-item>
- <!-- 购买信息采集 -->
- <el-form-item label="购买信息采集">
- <div class="purchase-field-section">
- <el-checkbox v-model="purchaseFieldsEnabled" @change="handlePurchaseFieldsToggle">
- 启用购买信息采集(用户购买时需填写/确认以下信息)
- </el-checkbox>
- <div v-if="purchaseFieldsEnabled && predefinedFields.length" class="purchase-field-list">
- <div
- v-for="(pf, idx) in predefinedFields"
- :key="pf.fieldKey"
- class="purchase-field-item"
- >
- <el-checkbox
- v-model="pf.selected"
- @change="syncPurchaseFieldOrder"
- >
- {{ pf.fieldName }}
- </el-checkbox>
- <el-checkbox
- v-if="pf.selected"
- v-model="pf.required"
- class="purchase-field-required"
- size="mini"
- >
- 必填
- </el-checkbox>
- <span v-if="pf.selected" class="purchase-field-drag">
- <i class="el-icon-rank"></i>
- </span>
- </div>
- </div>
- </div>
- </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, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields } from '@/api/admin'
- import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
- import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
- import ProductSkuEditor from './components/ProductSkuEditor.vue'
- import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
- import '@wangeditor/editor/dist/css/style.css'
- export default {
- name: 'ProductEdit',
- components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
- data() {
- return {
- form: {
- name: '',
- intro: '',
- description: '',
- coverImage: '',
- images: '',
- productType: 'physical',
- price: null,
- memberPrice: null,
- stock: null,
- vendorName: '',
- vendorId: null,
- distributionSystemId: null,
- deliveryMethod: 1,
- memberEligible: 'no',
- dimensionWeights: [],
- // 测评商品扩展
- assessmentType: 'single',
- totalSessions: 1,
- validityDays: 365,
- guideScope: 'all'
- },
- imageList: [],
- supplySystems: [],
- 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,
- pageLoading: false,
- purchaseFieldsEnabled: false,
- predefinedFields: [],
- editorConfig: {
- MENU_CONF: {
- uploadImage: {
- server: '',
- headers: {},
- maxNumberOfFiles: 1,
- fieldName: 'file',
- customAlert: (msg, type) => {
- this.$message && this.$message({ message: msg, type: type || 'error' })
- },
- onSuccess(res, insertFn) {
- if (res.code === 200) {
- insertFn(res.data.url || res.data)
- }
- }
- }
- }
- },
- toolbarConfig: {},
- editorInstance: null
- }
- },
- 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 }
- },
- selectedSystem() {
- if (!this.form.distributionSystemId || !this.supplySystems.length) return null
- return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
- }
- },
- mounted() {
- this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
- this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
- this.loadSupplySystems()
- this.loadPredefinedFields()
- if (this.isEdit) {
- this.loadProduct(this.$route.query.id)
- }
- },
- methods: {
- handleEditorCreated(editor) {
- this.editorInstance = editor
- },
- async loadSupplySystems() {
- try {
- const res = await listAllSupplySystems()
- if (res.data) {
- this.supplySystems = res.data
- }
- } catch (e) {
- console.error(e)
- }
- },
- handleSystemChange(systemId) {
- if (!systemId) return
- const system = this.supplySystems.find(s => s.id === systemId)
- if (system && system.platformProfitRate != null) {
- this.form.profitRate = Math.round(system.platformProfitRate * 10)
- }
- },
- async loadProduct(id) {
- this.pageLoading = true
- 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 != null) ? p.price / 100 : null,
- memberPrice: (p.memberPrice != null) ? p.memberPrice / 100 : null,
- stock: (p.stock != null) ? p.stock : null,
- vendorName: p.vendorName || '',
- vendorId: p.vendorId || null,
- distributionSystemId: p.distributionSystemId || null,
- deliveryMethod: p.deliveryMethod || 1,
- memberEligible: p.memberEligible || 'no',
- dimensionWeights: [],
- assessmentType: p.assessmentType || 'single',
- totalSessions: p.totalSessions || 1,
- validityDays: p.validityDays || 365,
- guideScope: p.guideScope || 'all'
- }
- if (p.images) {
- try {
- this.imageList = JSON.parse(p.images)
- } catch (e) {
- this.imageList = []
- }
- }
- // Load dimension weights
- this.loadDimensionWeights(p.id)
- // Load purchase fields config
- this.loadPurchaseFields(p.id)
- // v-model 已绑定 form.description,editor 内容自动更新
- }
- } catch (e) {
- this.$message.error('加载商品失败')
- } finally {
- this.pageLoading = false
- }
- },
- async loadDimensionWeights(productId) {
- try {
- const res = await getDimensionWeights('product', productId)
- if (res.code === 200 && res.data) {
- this.form.dimensionWeights = res.data.map(w => ({
- dimension: w.dimension,
- weight: w.weight,
- enabled: w.enabled
- }))
- }
- } catch (e) {
- // ignore
- }
- },
- async loadPredefinedFields() {
- try {
- const res = await getPredefinedPurchaseFields()
- if (res.code === 200 && Array.isArray(res.data)) {
- this.predefinedFields = res.data.map(f => ({
- fieldKey: f.fieldKey,
- fieldName: f.fieldName,
- selected: false,
- required: true
- }))
- }
- } catch (e) {
- console.warn('加载预定义字段失败', e)
- }
- },
- async loadPurchaseFields(productId) {
- try {
- const res = await getProductPurchaseFields(productId)
- if (res.code === 200 && Array.isArray(res.data)) {
- const configs = res.data
- this.purchaseFieldsEnabled = configs.length > 0
- this.predefinedFields.forEach(pf => {
- const match = configs.find(c => c.fieldKey === pf.fieldKey)
- pf.selected = !!match
- pf.required = match ? match.isRequired === 1 : false
- })
- }
- } catch (e) {
- console.warn('加载购买字段配置失败', e)
- }
- },
- handlePurchaseFieldsToggle(val) {
- if (!val) {
- this.predefinedFields.forEach(pf => { pf.selected = false })
- }
- },
- syncPurchaseFieldOrder() {
- // sortOrder is derived from selection order
- },
- buildPurchaseFieldPayload() {
- if (!this.purchaseFieldsEnabled) return []
- return this.predefinedFields
- .filter(pf => pf.selected)
- .map((pf, idx) => ({
- fieldKey: pf.fieldKey,
- fieldName: pf.fieldName,
- isRequired: pf.required ? 1 : 0,
- sortOrder: idx
- }))
- },
- 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 productData = {
- ...this.form,
- price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
- memberPrice: (this.form.memberPrice != null) ? Math.round(this.form.memberPrice * 100) : null,
- images: JSON.stringify(this.imageList)
- }
- // Extract dimensionWeights before sending to product API
- const dimensionWeights = productData.dimensionWeights
- delete productData.dimensionWeights
- // Build ProductCreateRequest payload
- const requestPayload = { product: productData }
- if (productData.productType === 'assessment') {
- requestPayload.assessmentExt = {
- assessmentType: productData.assessmentType,
- totalSessions: productData.totalSessions,
- validityDays: productData.validityDays,
- guideScope: productData.guideScope
- }
- }
- // Remove assessment ext fields from product object
- delete productData.assessmentType
- delete productData.totalSessions
- delete productData.validityDays
- delete productData.guideScope
- let res
- let productId = this.form.id
- if (this.isEdit) {
- res = await updateProduct(requestPayload)
- productId = this.form.id
- } else {
- res = await createProduct(requestPayload)
- // Get the created product id
- if (res.code === 200 && res.data) {
- productId = res.data.id || res.data
- }
- }
- if (res.code === 200) {
- // Save dimension weights
- if (productId && Array.isArray(dimensionWeights)) {
- try {
- await saveDimensionWeights('product', productId, dimensionWeights)
- } catch (e) {
- console.warn('保存维度权重失败', e)
- }
- }
- // Save purchase fields config
- if (productId) {
- try {
- const fields = this.buildPurchaseFieldPayload()
- await saveProductPurchaseFields(productId, fields)
- } catch (e) {
- console.warn('保存购买字段配置失败', e)
- }
- }
- 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;
- }
- .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;
- }
- .product-edit .el-card {
- width: 100%;
- }
- .product-edit .el-form {
- width: 100%;
- }
- .rich-editor {
- border: 1px solid #dcdfe6;
- border-radius: 4px;
- }
- .rich-editor /deep/ .w-e-toolbar {
- flex-wrap: wrap;
- }
- .rich-editor /deep/ .w-e-text-container {
- min-height: 300px;
- }
- .purchase-field-section {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .purchase-field-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 12px;
- background: #fafafa;
- border-radius: 4px;
- border: 1px solid #eee;
- }
- .purchase-field-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 12px;
- background: #fff;
- border: 1px solid #e8e8e8;
- border-radius: 4px;
- }
- .purchase-field-required {
- font-size: 12px;
- }
- .purchase-field-drag {
- color: #ccc;
- cursor: move;
- }
- </style>
|