ProductEdit.vue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123
  1. <template>
  2. <div class="product-edit admin-page">
  3. <el-card v-loading="pageLoading">
  4. <div slot="header" class="admin-page-header">
  5. <span class="admin-page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
  6. <div class="admin-page-actions">
  7. <el-button size="mini" @click="$router.back()">返回</el-button>
  8. </div>
  9. </div>
  10. <el-form ref="form" :model="form" :rules="rules" label-width="100px">
  11. <el-form-item label="商品名称" prop="name">
  12. <el-input v-model="form.name" placeholder="请输入商品名称" maxlength="100" />
  13. </el-form-item>
  14. <el-form-item label="商品类型" prop="productType">
  15. <el-select v-model="form.productType" placeholder="请选择商品类型" style="width: 200px;">
  16. <el-option label="实物商品" value="physical" />
  17. <el-option label="虚拟商品" value="virtual" />
  18. <el-option label="优惠券" value="coupon" />
  19. <el-option label="测评商品" value="assessment" />
  20. <el-option label="套餐" value="bundle" />
  21. </el-select>
  22. </el-form-item>
  23. <!-- 测评商品扩展信息 -->
  24. <template v-if="form.productType === 'assessment'">
  25. <el-form-item label="测评类型">
  26. <el-select v-model="form.assessmentType" placeholder="请选择测评类型" style="width: 200px;">
  27. <el-option label="单包" value="single" />
  28. <el-option label="组合" value="bundle" />
  29. </el-select>
  30. </el-form-item>
  31. <el-row :gutter="20">
  32. <el-col :span="8">
  33. <el-form-item label="测评次数">
  34. <el-input-number v-model="form.totalSessions" :min="1" :max="999" :precision="0" style="width: 100%;" controls />
  35. </el-form-item>
  36. </el-col>
  37. <el-col :span="8">
  38. <el-form-item label="有效期(天)">
  39. <el-input-number v-model="form.validityDays" :min="1" :max="3650" :precision="0" :default-value="365" style="width: 100%;" controls />
  40. </el-form-item>
  41. </el-col>
  42. <el-col :span="8">
  43. <el-form-item label="规划师范围">
  44. <el-select v-model="form.guideScope" placeholder="不限" style="width: 100%;">
  45. <el-option label="不限" value="all" />
  46. <el-option label="指定规划师" value="assigned" />
  47. <el-option label="自动分配" value="auto" />
  48. </el-select>
  49. </el-form-item>
  50. </el-col>
  51. </el-row>
  52. </template>
  53. <!-- 套餐商品扩展信息 -->
  54. <template v-if="form.productType === 'bundle'">
  55. <el-divider content-position="left">套餐组成</el-divider>
  56. <el-form-item label="组成商品">
  57. <div v-for="(item, idx) in bundleItems" :key="'bi' + idx" style="display:flex; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap;">
  58. <el-select v-model="item.childProductId" filterable remote placeholder="搜索子商品" :remote-method="(q) => searchBundleProduct(q, idx)" style="width:220px;" @change="(pid) => onBundleProductChange(pid, idx)">
  59. <el-option v-for="p in item.productOptions" :key="p.id" :label="(p.name || 'ID:' + p.id) + ' (库存:' + (p.stock || 0) + ')'" :value="p.id" />
  60. </el-select>
  61. <el-select v-if="item.skuOptions.length" v-model="item.childSkuId" placeholder="选择规格" clearable style="width:160px;">
  62. <el-option v-for="s in item.skuOptions" :key="s.id" :label="(s.specs || '默认') + ' (库存:' + (s.stock || 0) + ')'" :value="s.id" />
  63. </el-select>
  64. <el-input-number v-model="item.quantity" :min="1" :precision="0" size="small" style="width:110px;" />
  65. <el-button type="danger" size="mini" @click="removeBundleItem(idx)">删除</el-button>
  66. </div>
  67. <el-button type="primary" size="mini" plain @click="addBundleItem">+ 添加子商品</el-button>
  68. <div style="margin-top:8px; font-size:12px; color:#999;">套餐出库时自动完成所有组成商品出库(数量 × 套餐数量)</div>
  69. </el-form-item>
  70. </template>
  71. <!-- 关联成长档案 -->
  72. <el-divider content-position="left">关联成长档案</el-divider>
  73. <el-form-item label="关联成长档案">
  74. <el-switch
  75. v-model="growthArchive.enabled"
  76. active-text="开启"
  77. inactive-text="关闭"
  78. />
  79. <span style="margin-left: 10px; font-size: 12px; color: #999;">在成长档案上传页推荐该商品</span>
  80. </el-form-item>
  81. <el-form-item label="商品简介" prop="intro">
  82. <el-input v-model="form.intro" type="textarea" :rows="2" placeholder="简短描述" maxlength="200" />
  83. </el-form-item>
  84. <el-form-item label="排序值">
  85. <el-input-number v-model="form.sortOrder" :min="0" :precision="0" style="width: 200px;" controls />
  86. <span style="margin-left: 10px; font-size: 12px; color: #999;">越小越靠前</span>
  87. </el-form-item>
  88. <el-form-item label="商品描述" prop="description">
  89. <div class="rich-editor">
  90. <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
  91. <Editor :defaultConfig="editorConfig" v-model="form.description" @onCreated="handleEditorCreated" style="min-height: 300px;" />
  92. </div>
  93. </el-form-item>
  94. <el-form-item label="封面图片" prop="coverImage">
  95. <div class="cover-uploader">
  96. <el-upload
  97. :action="uploadActionUrl"
  98. :headers="uploadHeaders"
  99. :show-file-list="false"
  100. :on-success="handleCoverSuccess"
  101. :before-upload="beforeUpload"
  102. accept="image/*"
  103. >
  104. <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
  105. <i v-else class="el-icon-plus cover-icon"></i>
  106. </el-upload>
  107. <span class="cover-tip">点击上传封面图,建议尺寸 800x800</span>
  108. </div>
  109. </el-form-item>
  110. <el-form-item label="商品图片">
  111. <div class="images-uploader">
  112. <div v-for="(img, idx) in imageList" :key="idx" class="image-item">
  113. <img :src="img" class="image-preview" />
  114. <span class="image-remove" @click="removeImage(idx)">×</span>
  115. </div>
  116. <el-upload
  117. :action="uploadActionUrl"
  118. :headers="uploadHeaders"
  119. :show-file-list="false"
  120. :on-success="handleImageSuccess"
  121. :before-upload="beforeUpload"
  122. accept="image/*"
  123. multiple
  124. >
  125. <i class="el-icon-plus image-add-icon"></i>
  126. </el-upload>
  127. </div>
  128. </el-form-item>
  129. <el-row :gutter="20">
  130. <el-col :span="6">
  131. <el-form-item label="售价(元)" prop="price">
  132. <el-input-number v-model="form.price" :min="0" :precision="2" placeholder="0.00" style="width: 100%;" controls />
  133. </el-form-item>
  134. </el-col>
  135. <el-col :span="6">
  136. <el-form-item label="会员价(元)">
  137. <el-input-number v-model="form.memberPrice" :min="0" :precision="2" :step="0.01" placeholder="留空等于普通价" style="width: 100%;" controls />
  138. </el-form-item>
  139. </el-col>
  140. <el-col :span="6">
  141. <el-form-item label="库存" prop="stock">
  142. <el-input-number v-model="form.stock" :min="0" :precision="0" placeholder="0" style="width: 100%;" controls />
  143. </el-form-item>
  144. </el-col>
  145. <el-col :span="6">
  146. <el-form-item label="最小购买数">
  147. <el-input-number v-model="form.minQuantity" :min="1" :precision="0" placeholder="1" style="width: 100%;" controls />
  148. </el-form-item>
  149. </el-col>
  150. </el-row>
  151. <el-form-item label="所属体系">
  152. <el-select v-model="form.distributionSystemId" placeholder="不属于任何体系" clearable :disabled="isSupplierAdmin" style="width: 100%" @change="handleSystemChange">
  153. <el-option v-for="s in supplySystems" :key="s.id" :label="s.name" :value="s.id"></el-option>
  154. </el-select>
  155. <span v-if="selectedSystem" style="font-size: 12px; color: #999; margin-left: 8px">
  156. 平台留利: {{ selectedSystem.platformProfitRate }}% → {{ selectedSystem.platformProfitRate * 10 }}‰
  157. </span>
  158. </el-form-item>
  159. <el-form-item v-if="isSupplierAdmin" label="供应商">
  160. <el-input v-model="form.vendorName" placeholder="自动填写" disabled />
  161. </el-form-item>
  162. <el-form-item v-else label="供应商">
  163. <el-input v-model="form.vendorName" placeholder="供应商名称(管理员创建时填写)" />
  164. </el-form-item>
  165. <el-form-item label="配送方式">
  166. <el-radio-group v-model="form.deliveryMethod">
  167. <el-radio :label="1">快递</el-radio>
  168. <el-radio :label="2">自提</el-radio>
  169. <el-radio :label="3">两者皆可</el-radio>
  170. <el-radio :label="4">线上-可选人</el-radio>
  171. <el-radio :label="5">线上-单人</el-radio>
  172. </el-radio-group>
  173. </el-form-item>
  174. <!-- 线上-可选人(4):从已入驻服务商中选择 -->
  175. <el-form-item v-if="form.deliveryMethod === 4" label="线上服务者">
  176. <div class="service-person-list">
  177. <!-- 添加服务者下拉 -->
  178. <div class="add-person-row">
  179. <el-select v-model="selectedGuideId" placeholder="选择服务商(已入驻)" style="width: 260px"
  180. filterable @change="onSelectGuide">
  181. <el-option v-for="g in filteredGuides" :key="g.userId"
  182. :label="g.realName || g.nickname"
  183. :value="g.userId">
  184. <span style="float:left">{{ g.realName || g.nickname }}</span>
  185. <span style="float:right;color:#999;font-size:12px">{{ g.title || g.specialty || '' }}</span>
  186. </el-option>
  187. </el-select>
  188. <el-button type="primary" size="mini" style="margin-left:8px" @click="addSelectedGuide">添加</el-button>
  189. <div class="delivery-config-hint" style="margin-top:4px">时段由服务商在「服务商管理」中统一配置</div>
  190. </div>
  191. <!-- 已选服务者列表 -->
  192. <div v-for="(person, pIdx) in servicePersons" :key="pIdx" class="service-person-card">
  193. <div class="sp-header">
  194. <span class="sp-title">{{ person.name || '服务者 ' + (pIdx+1) }}</span>
  195. <el-button type="text" size="mini" @click="removeServicePerson(pIdx)">删除</el-button>
  196. </div>
  197. <el-row :gutter="10">
  198. <el-col :span="12">
  199. <el-input v-model="person.name" placeholder="展示名称" size="small" />
  200. </el-col>
  201. <el-col :span="12">
  202. <el-input v-model="person.title" placeholder="头衔" size="small" />
  203. </el-col>
  204. </el-row>
  205. <el-row :gutter="10" style="margin-top:8px">
  206. <el-col :span="12">
  207. <el-input v-model.number="person.price" placeholder="专属价格(元)" size="small" type="number" />
  208. </el-col>
  209. <el-col :span="12">
  210. <el-input v-model.number="person.originalPrice" placeholder="原价(元)" size="small" type="number" />
  211. </el-col>
  212. </el-row>
  213. <el-input v-model="person.intro" type="textarea" :rows="2" placeholder="个人介绍(可留空,自动从服务商资料代入)" size="small" style="margin-top:8px" />
  214. </div>
  215. </div>
  216. </el-form-item>
  217. <!-- 线上-单人(5):固定服务时段配置 -->
  218. <el-form-item v-if="form.deliveryMethod === 5" label="线上服务时段">
  219. <div class="online-slot-list">
  220. <div v-for="(slot, idx) in onlineSlots" :key="idx" class="sp-slot-row">
  221. <el-date-picker v-model="slot.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="开始时间" style="width: 190px" />
  222. <el-date-picker v-model="slot.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="结束时间" style="width: 190px; margin-left: 8px" />
  223. <el-input v-model.number="slot.maxCapacity" placeholder="容量" style="width: 90px; margin-left: 8px" />
  224. <el-button type="text" size="mini" @click="removeOnlineSlot(idx)">删除</el-button>
  225. </div>
  226. <el-button type="primary" plain size="mini" @click="addOnlineSlot">+ 添加时段</el-button>
  227. </div>
  228. </el-form-item>
  229. <el-form-item label="会员专属">
  230. <el-radio-group v-model="form.memberEligible">
  231. <el-radio label="no">否</el-radio>
  232. <el-radio label="yes">是</el-radio>
  233. </el-radio-group>
  234. </el-form-item>
  235. <el-form-item label="商品维度">
  236. <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
  237. </el-form-item>
  238. <el-form-item label="SKU规格">
  239. <ProductSkuEditor ref="skuEditor" :productId="form.id" />
  240. </el-form-item>
  241. <!-- 购买信息采集 -->
  242. <el-form-item label="购买信息采集">
  243. <div class="purchase-field-section">
  244. <el-checkbox v-model="purchaseFieldsEnabled" @change="handlePurchaseFieldsToggle">
  245. 启用购买信息采集(用户购买时需填写/确认以下信息)
  246. </el-checkbox>
  247. <div v-if="purchaseFieldsEnabled && predefinedFields.length" class="purchase-field-list">
  248. <div
  249. v-for="(pf, idx) in predefinedFields"
  250. :key="pf.fieldKey"
  251. class="purchase-field-item"
  252. >
  253. <el-checkbox
  254. v-model="pf.selected"
  255. @change="syncPurchaseFieldOrder"
  256. >
  257. {{ pf.fieldName }}
  258. </el-checkbox>
  259. <el-checkbox
  260. v-if="pf.selected"
  261. v-model="pf.required"
  262. class="purchase-field-required"
  263. size="mini"
  264. >
  265. 必填
  266. </el-checkbox>
  267. <span v-if="pf.selected" class="purchase-field-drag">
  268. <i class="el-icon-rank"></i>
  269. </span>
  270. </div>
  271. </div>
  272. </div>
  273. </el-form-item>
  274. <el-form-item label="赠送会员">
  275. <el-switch v-model="giftRuleEnabled" active-text="启用" inactive-text="停用" />
  276. <el-select v-if="giftRuleEnabled" v-model="giftRuleLevel" placeholder="选择赠送等级" style="margin-left: 12px; width: 200px">
  277. <el-option v-for="lv in membershipLevels" :key="lv.levelCode" :label="lv.levelName" :value="lv.levelCode" />
  278. </el-select>
  279. </el-form-item>
  280. <el-form-item>
  281. <el-button type="primary" :loading="saving" @click="handleSave">
  282. {{ isEdit ? '保存修改' : '创建商品' }}
  283. </el-button>
  284. <el-button @click="$router.back()">取消</el-button>
  285. </el-form-item>
  286. </el-form>
  287. </el-card>
  288. </div>
  289. </template>
  290. <script>
  291. import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides } from '@/api/admin'
  292. import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
  293. import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
  294. import ProductSkuEditor from './components/ProductSkuEditor.vue'
  295. import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
  296. import '@wangeditor/editor/dist/css/style.css'
  297. export default {
  298. name: 'ProductEdit',
  299. components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
  300. data() {
  301. return {
  302. form: {
  303. name: '',
  304. intro: '',
  305. description: '',
  306. coverImage: '',
  307. images: '',
  308. productType: 'physical',
  309. price: null,
  310. memberPrice: null,
  311. stock: null,
  312. minQuantity: 1,
  313. vendorName: '',
  314. vendorId: null,
  315. distributionSystemId: null,
  316. deliveryMethod: 1,
  317. memberEligible: 'no',
  318. dimensionWeights: [],
  319. // 测评商品扩展
  320. assessmentType: 'single',
  321. totalSessions: 1,
  322. validityDays: 365,
  323. guideScope: 'all',
  324. sortOrder: 0
  325. },
  326. bundleItems: [], // 套餐组成: [{childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
  327. servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice}]
  328. approvedGuides: [], // 已入驻服务商列表
  329. selectedGuideId: '',
  330. guideSearchQuery: '',
  331. onlineSlots: [], // 单人线上时段(deliveryMethod=5): [{startTime,endTime,maxCapacity}]
  332. growthArchive: {
  333. enabled: false,
  334. sortOrder: 0
  335. },
  336. imageList: [],
  337. supplySystems: [],
  338. giftRuleEnabled: false,
  339. giftRuleLevel: '',
  340. membershipLevels: [],
  341. rules: {
  342. name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  343. productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
  344. price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
  345. stock: [{ required: true, message: '请输入库存', trigger: 'blur' }]
  346. },
  347. saving: false,
  348. pageLoading: false,
  349. purchaseFieldsEnabled: false,
  350. predefinedFields: [],
  351. editorConfig: {
  352. MENU_CONF: {
  353. uploadImage: {
  354. server: '',
  355. headers: {},
  356. maxNumberOfFiles: 1,
  357. maxFileSize: 5 * 1024 * 1024,
  358. fieldName: 'file',
  359. customAlert: (msg, type) => {
  360. this.$message && this.$message({ message: msg, type: type || 'error' })
  361. },
  362. onError: (file, err, res) => {
  363. const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
  364. this.$message && this.$message({ message: msg, type: 'error' })
  365. },
  366. customInsert(res, insertFn) {
  367. if (res.code === 200) {
  368. const baseURL = process.env.VUE_APP_BASE_API || ''
  369. const url = res.data && res.data.url ? res.data.url : res.data
  370. insertFn(baseURL + url)
  371. }
  372. }
  373. }
  374. }
  375. },
  376. addBundleItem() {
  377. this.bundleItems.push({
  378. childProductId: null,
  379. childSkuId: null,
  380. quantity: 1,
  381. productOptions: [],
  382. skuOptions: []
  383. })
  384. },
  385. removeBundleItem(idx) {
  386. this.bundleItems.splice(idx, 1)
  387. },
  388. async searchBundleProduct(query, idx) {
  389. if (!query || query.length < 1) return
  390. try {
  391. var res = await getProductList({ keyword: query, page: 1, size: 20 })
  392. if (res.code === 200 && res.data && Array.isArray(res.data.records)) {
  393. this.bundleItems[idx].productOptions = res.data.records
  394. }
  395. } catch (e) { /* ignore */ }
  396. },
  397. async onBundleProductChange(productId, idx) {
  398. var item = this.bundleItems[idx]
  399. item.childSkuId = null
  400. item.skuOptions = []
  401. if (!productId) return
  402. try {
  403. var res = await getProductSkuList(productId)
  404. if (res.code === 200 && Array.isArray(res.data)) {
  405. item.skuOptions = res.data
  406. }
  407. } catch (e) { /* ignore */ }
  408. },
  409. buildBundleItemPayload() {
  410. return this.bundleItems
  411. .filter(function(b) { return b.childProductId })
  412. .map(function(b) {
  413. return {
  414. childProductId: b.childProductId,
  415. childSkuId: b.childSkuId || null,
  416. quantity: b.quantity || 1
  417. }
  418. })
  419. },
  420. async loadBundleItems(productId) {
  421. try {
  422. var res = await getBundleItems(productId)
  423. if (res.code === 200 && Array.isArray(res.data)) {
  424. this.bundleItems = res.data.map(function(b) {
  425. return {
  426. childProductId: b.childProductId,
  427. childSkuId: b.childSkuId || null,
  428. quantity: b.quantity || 1,
  429. productOptions: [],
  430. skuOptions: []
  431. }
  432. })
  433. this.bundleItems.forEach(function(item, idx) {
  434. if (item.childProductId) {
  435. this.onBundleProductChange(item.childProductId, idx)
  436. }
  437. }, this)
  438. }
  439. } catch (e) { /* ignore */ }
  440. },
  441. toolbarConfig: {},
  442. editorInstance: null
  443. }
  444. },
  445. computed: {
  446. isEdit() {
  447. return !!this.$route.query.id
  448. },
  449. isSupplierAdmin() {
  450. const rolesStr = localStorage.getItem('roles')
  451. const roles = rolesStr ? JSON.parse(rolesStr) : []
  452. return roles.includes('supplier_admin')
  453. },
  454. uploadActionUrl() {
  455. const baseURL = process.env.VUE_APP_BASE_API || ''
  456. return baseURL + '/api/admin/articles/upload/image'
  457. },
  458. uploadHeaders() {
  459. const token = localStorage.getItem('token')
  460. return { Authorization: 'Bearer ' + token }
  461. },
  462. selectedSystem() {
  463. if (!this.form.distributionSystemId || !this.supplySystems.length) return null
  464. return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
  465. },
  466. filteredGuides() {
  467. if (!this.approvedGuides.length) return this.approvedGuides
  468. if (!this.guideSearchQuery) return this.approvedGuides
  469. var q = this.guideSearchQuery.toLowerCase()
  470. return this.approvedGuides.filter(function(g) {
  471. return (g.realName || '').toLowerCase().indexOf(q) !== -1
  472. || (g.nickname || '').toLowerCase().indexOf(q) !== -1
  473. || (g.title || '').toLowerCase().indexOf(q) !== -1
  474. || (g.specialty || '').toLowerCase().indexOf(q) !== -1
  475. })
  476. }
  477. },
  478. created() {
  479. this.loadApprovedGuides()
  480. this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
  481. this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
  482. },
  483. mounted() {
  484. this.loadSupplySystems()
  485. this.loadPredefinedFields()
  486. if (this.isEdit) {
  487. this.loadProduct(this.$route.query.id)
  488. }
  489. },
  490. methods: {
  491. handleEditorCreated(editor) {
  492. this.editorInstance = editor
  493. },
  494. async loadSupplySystems() {
  495. if (this.isSupplierAdmin) {
  496. try {
  497. const res = await getMySupplySystem()
  498. if (res.data) {
  499. this.supplySystems = [res.data]
  500. this.form.distributionSystemId = res.data.id
  501. this.handleSystemChange(res.data.id)
  502. }
  503. } catch (e) {
  504. console.error(e)
  505. }
  506. return
  507. }
  508. try {
  509. const res = await listAllSupplySystems()
  510. if (res.data) {
  511. this.supplySystems = res.data
  512. }
  513. } catch (e) {
  514. console.error(e)
  515. }
  516. },
  517. handleSystemChange(systemId) {
  518. if (!systemId) return
  519. const system = this.supplySystems.find(s => s.id === systemId)
  520. if (system && system.platformProfitRate != null) {
  521. this.form.profitRate = Math.round(system.platformProfitRate * 10)
  522. }
  523. },
  524. async loadProduct(id) {
  525. this.pageLoading = true
  526. try {
  527. const res = await getProduct(id)
  528. if (res.code === 200 && res.data) {
  529. const p = res.data
  530. this.form = {
  531. id: p.id,
  532. name: p.name || '',
  533. intro: p.intro || '',
  534. description: p.description || '',
  535. coverImage: p.coverImage || '',
  536. images: p.images || '',
  537. productType: p.productType || 'physical',
  538. price: (p.price != null) ? p.price / 100 : null,
  539. memberPrice: (p.memberPrice != null && p.memberPrice > 0) ? p.memberPrice / 100 : null,
  540. stock: (p.stock != null) ? p.stock : null,
  541. minQuantity: p.minQuantity || null,
  542. vendorName: p.vendorName || '',
  543. vendorId: p.vendorId || null,
  544. distributionSystemId: p.distributionSystemId || null,
  545. deliveryMethod: p.deliveryMethod || 1,
  546. memberEligible: p.memberEligible || 'no',
  547. dimensionWeights: [],
  548. assessmentType: p.assessmentType || 'single',
  549. totalSessions: p.totalSessions || 1,
  550. validityDays: p.validityDays || 365,
  551. guideScope: p.guideScope || 'all',
  552. sortOrder: p.sortOrder != null ? p.sortOrder : 0
  553. }
  554. if (p.images) {
  555. try {
  556. this.imageList = JSON.parse(p.images)
  557. } catch (e) {
  558. this.imageList = []
  559. }
  560. }
  561. // Load dimension weights
  562. this.loadDimensionWeights(p.id)
  563. // Load purchase fields config
  564. this.loadPurchaseFields(p.id)
  565. // 赠送会员规则回显
  566. this.loadGiftRule(p.id)
  567. // Load growth archive config
  568. this.loadGrowthArchive(p.id)
  569. // Load delivery config (service persons / online slots)
  570. this.loadDeliveryConfig(p.id)
  571. // Load bundle items config(套餐商品)
  572. if (p.productType === 'bundle') {
  573. this.loadBundleItems(p.id)
  574. }
  575. // v-model 已绑定 form.description,editor 内容自动更新
  576. }
  577. } catch (e) {
  578. this.$message.error('加载商品失败')
  579. } finally {
  580. this.pageLoading = false
  581. }
  582. },
  583. async loadDimensionWeights(productId) {
  584. try {
  585. const res = await getDimensionWeights('product', productId)
  586. if (res.code === 200 && res.data) {
  587. this.form.dimensionWeights = res.data.map(w => ({
  588. dimension: w.dimension,
  589. weight: w.weight,
  590. enabled: w.enabled
  591. }))
  592. }
  593. } catch (e) {
  594. // ignore
  595. }
  596. },
  597. async loadPredefinedFields() {
  598. try {
  599. const res = await getPredefinedPurchaseFields()
  600. if (res.code === 200 && Array.isArray(res.data)) {
  601. this.predefinedFields = res.data.map(f => ({
  602. fieldKey: f.fieldKey,
  603. fieldName: f.fieldName,
  604. selected: false,
  605. required: true
  606. }))
  607. }
  608. } catch (e) {
  609. console.warn('加载预定义字段失败', e)
  610. }
  611. },
  612. async loadPurchaseFields(productId) {
  613. try {
  614. const res = await getProductPurchaseFields(productId)
  615. if (res.code === 200 && Array.isArray(res.data)) {
  616. const configs = res.data
  617. this.purchaseFieldsEnabled = configs.length > 0
  618. this.predefinedFields.forEach(pf => {
  619. const match = configs.find(c => c.fieldKey === pf.fieldKey)
  620. pf.selected = !!match
  621. pf.required = match ? match.isRequired === 1 : false
  622. })
  623. }
  624. } catch (e) {
  625. console.warn('加载购买字段配置失败', e)
  626. }
  627. },
  628. async loadGiftRule(productId) {
  629. try {
  630. const lvRes = await listMembershipLevels()
  631. if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
  632. this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
  633. }
  634. const ruleRes = await listProductGiftRules(productId)
  635. if (ruleRes.code === 200 && Array.isArray(ruleRes.data) && ruleRes.data.length > 0) {
  636. const active = ruleRes.data.find(r => r.status === 1)
  637. if (active) {
  638. this.giftRuleEnabled = true
  639. this.giftRuleLevel = active.levelCode
  640. }
  641. }
  642. } catch (e) {
  643. console.warn('加载赠送会员规则失败', e)
  644. }
  645. },
  646. async loadGrowthArchive(productId) {
  647. try {
  648. const res = await getGrowthArchiveProduct(productId)
  649. if (res.code === 200 && res.data) {
  650. this.growthArchive = {
  651. enabled: res.data.status === 1,
  652. sortOrder: res.data.sortOrder || 0
  653. }
  654. }
  655. } catch (e) {
  656. console.warn('加载成长档案关联配置失败', e)
  657. }
  658. },
  659. async loadDeliveryConfig(productId) {
  660. if (this.form.deliveryMethod === 4) {
  661. try {
  662. const res = await listDeliveryServicePersons(productId)
  663. if (res.code === 200 && Array.isArray(res.data)) {
  664. this.servicePersons = res.data.map(p => ({
  665. userId: p.userId || '',
  666. name: p.name || '',
  667. title: p.title || '',
  668. intro: p.intro || '',
  669. avatar: p.avatar || '',
  670. price: (p.price != null) ? p.price / 100 : null,
  671. originalPrice: (p.originalPrice != null) ? p.originalPrice / 100 : null
  672. }))
  673. }
  674. } catch (e) {
  675. console.warn('加载线上服务者失败', e)
  676. }
  677. } else if (this.form.deliveryMethod === 5) {
  678. try {
  679. const res = await listDeliveryOnlineSlots(productId)
  680. if (res.code === 200 && Array.isArray(res.data)) {
  681. this.onlineSlots = res.data.map(s => ({
  682. startTime: (s.startTime || '').replace('T', ' ').substring(0, 19),
  683. endTime: (s.endTime || '').replace('T', ' ').substring(0, 19),
  684. maxCapacity: s.maxCapacity || 1
  685. }))
  686. }
  687. } catch (e) {
  688. console.warn('加载线上服务时段失败', e)
  689. }
  690. }
  691. },
  692. addServicePerson() {
  693. this.servicePersons.push({ userId: '', name: '', title: '', intro: '', avatar: '', price: null, originalPrice: null })
  694. },
  695. removeServicePerson(idx) {
  696. this.servicePersons.splice(idx, 1)
  697. },
  698. addOnlineSlot() {
  699. this.onlineSlots.push({ startTime: '', endTime: '', maxCapacity: 1 })
  700. },
  701. removeOnlineSlot(idx) {
  702. this.onlineSlots.splice(idx, 1)
  703. },
  704. buildServicePersonPayload() {
  705. if (this.form.deliveryMethod !== 4) return []
  706. return this.servicePersons
  707. .filter(p => p.userId)
  708. .map(p => ({
  709. userId: Number(p.userId),
  710. name: p.name || '',
  711. title: p.title || '',
  712. intro: p.intro || '',
  713. avatar: p.avatar || '',
  714. price: (p.price != null) ? Math.round(p.price * 100) : 0,
  715. originalPrice: (p.originalPrice != null) ? Math.round(p.originalPrice * 100) : null
  716. }))
  717. },
  718. buildOnlineSlotPayload() {
  719. if (this.form.deliveryMethod !== 5) return []
  720. return this.onlineSlots.filter(s => s.startTime && s.endTime).map(s => ({
  721. startTime: s.startTime,
  722. endTime: s.endTime,
  723. maxCapacity: s.maxCapacity || 1
  724. }))
  725. },
  726. async loadApprovedGuides() {
  727. try {
  728. var res = await getApprovedGuides()
  729. if (res.code === 200 && Array.isArray(res.data)) {
  730. this.approvedGuides = res.data
  731. }
  732. } catch (e) {
  733. console.warn('加载服务商列表失败', e)
  734. }
  735. },
  736. onSelectGuide(userId) {
  737. if (!userId) return
  738. var guide = this.approvedGuides.find(function(g) { return g.userId == userId })
  739. if (!guide) return
  740. this.servicePersons.push({
  741. userId: String(userId),
  742. name: guide.realName || guide.nickname || '',
  743. title: guide.title || '',
  744. intro: guide.intro || guide.bio || '',
  745. avatar: guide.avatar || '',
  746. price: null,
  747. originalPrice: null
  748. })
  749. this.selectedGuideId = ''
  750. },
  751. addSelectedGuide() {
  752. this.onSelectGuide(this.selectedGuideId)
  753. },
  754. handlePurchaseFieldsToggle(val) {
  755. if (!val) {
  756. this.predefinedFields.forEach(pf => { pf.selected = false })
  757. }
  758. },
  759. syncPurchaseFieldOrder() {
  760. // sortOrder is derived from selection order
  761. },
  762. buildPurchaseFieldPayload() {
  763. if (!this.purchaseFieldsEnabled) return []
  764. return this.predefinedFields
  765. .filter(pf => pf.selected)
  766. .map((pf, idx) => ({
  767. fieldKey: pf.fieldKey,
  768. fieldName: pf.fieldName,
  769. isRequired: pf.required ? 1 : 0,
  770. sortOrder: idx
  771. }))
  772. },
  773. beforeUpload(file) {
  774. const isImage = file.type.startsWith('image/')
  775. const isLt5M = file.size / 1024 / 1024 < 5
  776. if (!isImage) {
  777. this.$message.error('只能上传图片文件')
  778. return false
  779. }
  780. if (!isLt5M) {
  781. this.$message.error('图片大小不能超过 5MB')
  782. return false
  783. }
  784. return true
  785. },
  786. handleCoverSuccess(res) {
  787. if (res.code === 200 && res.data) {
  788. this.form.coverImage = res.data.url || res.data
  789. this.$message.success('封面上传成功')
  790. } else {
  791. this.$message.error(res.message || '上传失败')
  792. }
  793. },
  794. handleImageSuccess(res) {
  795. if (res.code === 200 && res.data) {
  796. const url = res.data.url || res.data
  797. this.imageList.push(url)
  798. this.form.images = JSON.stringify(this.imageList)
  799. this.$message.success('图片上传成功')
  800. } else {
  801. this.$message.error(res.message || '上传失败')
  802. }
  803. },
  804. removeImage(idx) {
  805. this.imageList.splice(idx, 1)
  806. this.form.images = JSON.stringify(this.imageList)
  807. },
  808. async handleSave() {
  809. this.$refs.form.validate(async (valid) => {
  810. if (!valid) return
  811. this.saving = true
  812. try {
  813. const productData = {
  814. ...this.form,
  815. price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
  816. memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
  817. images: JSON.stringify(this.imageList)
  818. }
  819. // Extract dimensionWeights before sending to product API
  820. const dimensionWeights = productData.dimensionWeights
  821. delete productData.dimensionWeights
  822. // Build ProductCreateRequest payload
  823. const requestPayload = { product: productData }
  824. if (productData.productType === 'assessment') {
  825. requestPayload.assessmentExt = {
  826. assessmentType: productData.assessmentType,
  827. totalSessions: productData.totalSessions,
  828. validityDays: productData.validityDays,
  829. guideScope: productData.guideScope
  830. }
  831. }
  832. // Remove assessment ext fields from product object
  833. delete productData.assessmentType
  834. delete productData.totalSessions
  835. delete productData.validityDays
  836. delete productData.guideScope
  837. let res
  838. let productId = this.form.id
  839. if (this.isEdit) {
  840. res = await updateProduct(requestPayload)
  841. productId = this.form.id
  842. } else {
  843. res = await createProduct(requestPayload)
  844. if (res.code === 200 && res.data) {
  845. productId = res.data.id || res.data
  846. this.form.id = productId
  847. }
  848. }
  849. if (res.code === 200) {
  850. // Save dimension weights
  851. if (productId && Array.isArray(dimensionWeights)) {
  852. try {
  853. await saveDimensionWeights('product', productId, dimensionWeights)
  854. } catch (e) {
  855. console.warn('保存维度权重失败', e)
  856. }
  857. }
  858. // Save purchase fields config
  859. if (productId) {
  860. try {
  861. const fields = this.buildPurchaseFieldPayload()
  862. await saveProductPurchaseFields(productId, fields)
  863. } catch (e) {
  864. console.warn('保存购买字段配置失败', e)
  865. }
  866. }
  867. // 保存赠送会员规则
  868. if (productId) {
  869. try {
  870. await saveProductGiftRule(productId, this.giftRuleEnabled ? 1 : 0,
  871. this.giftRuleEnabled ? this.giftRuleLevel : '')
  872. } catch (e) {
  873. console.warn('保存赠送会员规则失败', e)
  874. }
  875. }
  876. // Save growth archive config (only for existing products)
  877. if (this.form.id) {
  878. try {
  879. await saveGrowthArchiveProduct({
  880. productId: this.form.id,
  881. status: this.growthArchive.enabled ? 1 : 0
  882. })
  883. } catch (e) {
  884. this.$message.error('保存成长档案关联失败')
  885. console.warn('保存成长档案关联失败', e)
  886. }
  887. }
  888. // Save delivery config (service persons / online slots)
  889. if (productId) {
  890. try {
  891. await saveDeliveryServicePersons(productId, this.buildServicePersonPayload())
  892. await saveDeliveryOnlineSlots(productId, this.buildOnlineSlotPayload())
  893. } catch (e) {
  894. this.$message.error('保存线上服务配置失败:' + (e.message || e))
  895. console.warn('保存线上服务配置失败', e)
  896. }
  897. }
  898. // Save bundle items config(套餐商品组成)
  899. if (productId) {
  900. try {
  901. var bundlePayload = this.buildBundleItemPayload()
  902. await saveBundleItems(productId, bundlePayload)
  903. } catch (e) {
  904. this.$message.error('保存套餐组成失败')
  905. console.warn('保存套餐组成失败', e)
  906. }
  907. }
  908. this.$message.success(this.isEdit ? '保存成功' : '创建成功')
  909. this.$router.push('/product-manage')
  910. } else {
  911. this.$message.error(res.message || '操作失败')
  912. }
  913. } catch (e) {
  914. this.$message.error('操作失败')
  915. } finally {
  916. this.saving = false
  917. }
  918. })
  919. }
  920. }
  921. }
  922. </script>
  923. <style scoped>
  924. .product-edit {
  925. padding: 20px;
  926. display: block;
  927. min-height: auto;
  928. }
  929. .product-edit > .el-card {
  930. display: block;
  931. min-height: auto;
  932. }
  933. .product-edit > .el-card /deep/ .el-card__body {
  934. display: block;
  935. min-height: auto;
  936. overflow: visible;
  937. }
  938. .cover-uploader {
  939. display: flex;
  940. align-items: center;
  941. gap: 12px;
  942. }
  943. .cover-preview {
  944. width: 100px;
  945. height: 100px;
  946. object-fit: cover;
  947. border-radius: 4px;
  948. border: 1px solid #ddd;
  949. }
  950. .cover-icon {
  951. width: 100px;
  952. height: 100px;
  953. line-height: 100px;
  954. text-align: center;
  955. font-size: 28px;
  956. color: #8c939d;
  957. border: 1px dashed #d9d9d9;
  958. border-radius: 4px;
  959. display: block;
  960. cursor: pointer;
  961. }
  962. .cover-icon:hover {
  963. border-color: #409eff;
  964. color: #409eff;
  965. }
  966. .cover-tip {
  967. font-size: 12px;
  968. color: #999;
  969. }
  970. .images-uploader {
  971. display: flex;
  972. flex-wrap: wrap;
  973. gap: 8px;
  974. }
  975. .image-item {
  976. position: relative;
  977. width: 80px;
  978. height: 80px;
  979. }
  980. .image-preview {
  981. width: 80px;
  982. height: 80px;
  983. object-fit: cover;
  984. border-radius: 4px;
  985. border: 1px solid #ddd;
  986. }
  987. .image-remove {
  988. position: absolute;
  989. top: -6px;
  990. right: -6px;
  991. width: 18px;
  992. height: 18px;
  993. background: #f56c6c;
  994. color: #fff;
  995. border-radius: 50%;
  996. text-align: center;
  997. line-height: 18px;
  998. font-size: 12px;
  999. cursor: pointer;
  1000. }
  1001. .image-add-icon {
  1002. width: 80px;
  1003. height: 80px;
  1004. line-height: 80px;
  1005. text-align: center;
  1006. font-size: 24px;
  1007. color: #8c939d;
  1008. border: 1px dashed #d9d9d9;
  1009. border-radius: 4px;
  1010. display: block;
  1011. cursor: pointer;
  1012. }
  1013. .image-add-icon:hover {
  1014. border-color: #409eff;
  1015. color: #409eff;
  1016. }
  1017. .product-edit .el-card {
  1018. width: 100%;
  1019. }
  1020. .product-edit .el-form {
  1021. width: 100%;
  1022. }
  1023. .rich-editor {
  1024. border: 1px solid #dcdfe6;
  1025. border-radius: 4px;
  1026. }
  1027. .rich-editor /deep/ .w-e-toolbar {
  1028. flex-wrap: wrap;
  1029. }
  1030. .rich-editor /deep/ .w-e-text-container {
  1031. min-height: 300px;
  1032. }
  1033. .purchase-field-section {
  1034. display: flex;
  1035. flex-direction: column;
  1036. gap: 12px;
  1037. }
  1038. .purchase-field-list {
  1039. display: flex;
  1040. flex-wrap: wrap;
  1041. gap: 8px;
  1042. padding: 12px;
  1043. background: #fafafa;
  1044. border-radius: 4px;
  1045. border: 1px solid #eee;
  1046. }
  1047. .purchase-field-item {
  1048. display: flex;
  1049. align-items: center;
  1050. gap: 8px;
  1051. padding: 6px 12px;
  1052. background: #fff;
  1053. border: 1px solid #e8e8e8;
  1054. border-radius: 4px;
  1055. }
  1056. .purchase-field-required {
  1057. font-size: 12px;
  1058. }
  1059. .purchase-field-drag {
  1060. color: #ccc;
  1061. cursor: move;
  1062. }
  1063. .service-person-card {
  1064. border: 1px solid #e8e8e8;
  1065. border-radius: 4px;
  1066. padding: 10px;
  1067. margin-bottom: 10px;
  1068. background: #fafafa;
  1069. }
  1070. .sp-header {
  1071. display: flex;
  1072. justify-content: space-between;
  1073. align-items: center;
  1074. margin-bottom: 8px;
  1075. }
  1076. .sp-title {
  1077. font-weight: 600;
  1078. font-size: 13px;
  1079. }
  1080. .sp-slots {
  1081. margin-top: 8px;
  1082. padding: 8px;
  1083. background: #fff;
  1084. border-radius: 4px;
  1085. }
  1086. .sp-slot-row {
  1087. display: flex;
  1088. align-items: center;
  1089. margin-bottom: 6px;
  1090. }
  1091. .delivery-config-hint {
  1092. margin-top: 6px;
  1093. font-size: 12px;
  1094. color: #999;
  1095. }
  1096. </style>