ProductEdit.vue 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262
  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 label="可选赠品">
  281. <div style="width: 100%">
  282. <div v-for="lv in membershipLevels" :key="lv.levelCode" class="gift-config-level">
  283. <div class="gift-config-level-title">{{ lv.levelName }} 赠品</div>
  284. <div v-for="(item, idx) in getGiftItems(lv.levelCode)" :key="getGiftItemKey(item, idx)" class="gift-config-item">
  285. <el-select v-model="item.giftType" size="mini" style="width: 110px; margin-right: 8px">
  286. <el-option label="赠品商品" value="product" />
  287. <el-option label="CF值" value="cf" />
  288. </el-select>
  289. <el-select v-if="item.giftType === 'product'" v-model="item.giftProductId" size="mini" filterable placeholder="选择赠品商品" style="width: 260px; margin-right: 8px">
  290. <el-option v-for="p in onShelfProducts" :key="p.id" :label="p.name" :value="p.id" />
  291. </el-select>
  292. <el-input v-if="item.giftType === 'cf'" v-model.number="item.cfValue" size="mini" placeholder="CF值数量" type="number" style="width: 140px; margin-right: 8px" />
  293. <el-button size="mini" type="text" @click="removeGiftItem(lv.levelCode, idx)">删除</el-button>
  294. </div>
  295. <el-button size="mini" type="text" @click="addGiftItem(lv.levelCode)">+ 添加赠品</el-button>
  296. </div>
  297. </div>
  298. </el-form-item>
  299. <el-form-item>
  300. <el-button type="primary" :loading="saving" @click="handleSave">
  301. {{ isEdit ? '保存修改' : '创建商品' }}
  302. </el-button>
  303. <el-button @click="$router.back()">取消</el-button>
  304. </el-form-item>
  305. </el-form>
  306. </el-card>
  307. </div>
  308. </template>
  309. <script>
  310. import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides, saveProductGift, getProductGiftList } from '@/api/admin'
  311. import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
  312. import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
  313. import ProductSkuEditor from './components/ProductSkuEditor.vue'
  314. import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
  315. import '@wangeditor/editor/dist/css/style.css'
  316. export default {
  317. name: 'ProductEdit',
  318. components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
  319. data() {
  320. return {
  321. form: {
  322. name: '',
  323. intro: '',
  324. description: '',
  325. coverImage: '',
  326. images: '',
  327. productType: 'physical',
  328. price: null,
  329. memberPrice: null,
  330. stock: null,
  331. minQuantity: 1,
  332. vendorName: '',
  333. vendorId: null,
  334. distributionSystemId: null,
  335. deliveryMethod: 1,
  336. memberEligible: 'no',
  337. dimensionWeights: [],
  338. // 测评商品扩展
  339. assessmentType: 'single',
  340. totalSessions: 1,
  341. validityDays: 365,
  342. guideScope: 'all',
  343. sortOrder: 0
  344. },
  345. bundleItems: [], // 套餐组成: [{childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
  346. servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice}]
  347. approvedGuides: [], // 已入驻服务商列表
  348. selectedGuideId: '',
  349. guideSearchQuery: '',
  350. onlineSlots: [], // 单人线上时段(deliveryMethod=5): [{startTime,endTime,maxCapacity}]
  351. growthArchive: {
  352. enabled: false,
  353. sortOrder: 0
  354. },
  355. imageList: [],
  356. supplySystems: [],
  357. giftRuleEnabled: false,
  358. giftRuleLevel: '',
  359. membershipLevels: [],
  360. // 可选赠品配置:{ [levelCode]: [{giftType,giftProductId,cfValue,sortOrder}] }
  361. giftConfigsByLevel: {},
  362. onShelfProducts: [],
  363. rules: {
  364. name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  365. productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
  366. price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
  367. stock: [{ required: true, message: '请输入库存', trigger: 'blur' }]
  368. },
  369. saving: false,
  370. pageLoading: false,
  371. purchaseFieldsEnabled: false,
  372. predefinedFields: [],
  373. editorConfig: {
  374. MENU_CONF: {
  375. uploadImage: {
  376. server: '',
  377. headers: {},
  378. maxNumberOfFiles: 1,
  379. maxFileSize: 5 * 1024 * 1024,
  380. fieldName: 'file',
  381. customAlert: (msg, type) => {
  382. this.$message && this.$message({ message: msg, type: type || 'error' })
  383. },
  384. onError: (file, err, res) => {
  385. const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
  386. this.$message && this.$message({ message: msg, type: 'error' })
  387. },
  388. customInsert(res, insertFn) {
  389. if (res.code === 200) {
  390. const baseURL = process.env.VUE_APP_BASE_API || ''
  391. const url = res.data && res.data.url ? res.data.url : res.data
  392. insertFn(baseURL + url)
  393. }
  394. }
  395. }
  396. }
  397. },
  398. addBundleItem() {
  399. this.bundleItems.push({
  400. childProductId: null,
  401. childSkuId: null,
  402. quantity: 1,
  403. productOptions: [],
  404. skuOptions: []
  405. })
  406. },
  407. removeBundleItem(idx) {
  408. this.bundleItems.splice(idx, 1)
  409. },
  410. async searchBundleProduct(query, idx) {
  411. if (!query || query.length < 1) return
  412. try {
  413. var res = await getProductList({ keyword: query, page: 1, size: 20 })
  414. if (res.code === 200 && res.data && Array.isArray(res.data.records)) {
  415. this.bundleItems[idx].productOptions = res.data.records
  416. }
  417. } catch (e) { /* ignore */ }
  418. },
  419. async onBundleProductChange(productId, idx) {
  420. var item = this.bundleItems[idx]
  421. item.childSkuId = null
  422. item.skuOptions = []
  423. if (!productId) return
  424. try {
  425. var res = await getProductSkuList(productId)
  426. if (res.code === 200 && Array.isArray(res.data)) {
  427. item.skuOptions = res.data
  428. }
  429. } catch (e) { /* ignore */ }
  430. },
  431. buildBundleItemPayload() {
  432. return this.bundleItems
  433. .filter(function(b) { return b.childProductId })
  434. .map(function(b) {
  435. return {
  436. childProductId: b.childProductId,
  437. childSkuId: b.childSkuId || null,
  438. quantity: b.quantity || 1
  439. }
  440. })
  441. },
  442. async loadBundleItems(productId) {
  443. try {
  444. var res = await getBundleItems(productId)
  445. if (res.code === 200 && Array.isArray(res.data)) {
  446. this.bundleItems = res.data.map(function(b) {
  447. return {
  448. childProductId: b.childProductId,
  449. childSkuId: b.childSkuId || null,
  450. quantity: b.quantity || 1,
  451. productOptions: [],
  452. skuOptions: []
  453. }
  454. })
  455. this.bundleItems.forEach(function(item, idx) {
  456. if (item.childProductId) {
  457. this.onBundleProductChange(item.childProductId, idx)
  458. }
  459. }, this)
  460. }
  461. } catch (e) { /* ignore */ }
  462. },
  463. toolbarConfig: {},
  464. editorInstance: null
  465. }
  466. },
  467. computed: {
  468. isEdit() {
  469. return !!this.$route.query.id
  470. },
  471. isSupplierAdmin() {
  472. const rolesStr = localStorage.getItem('roles')
  473. const roles = rolesStr ? JSON.parse(rolesStr) : []
  474. return roles.includes('supplier') || roles.includes('supplier_admin')
  475. },
  476. uploadActionUrl() {
  477. const baseURL = process.env.VUE_APP_BASE_API || ''
  478. return baseURL + '/api/admin/articles/upload/image'
  479. },
  480. uploadHeaders() {
  481. const token = localStorage.getItem('token')
  482. return { Authorization: 'Bearer ' + token }
  483. },
  484. selectedSystem() {
  485. if (!this.form.distributionSystemId || !this.supplySystems.length) return null
  486. return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
  487. },
  488. filteredGuides() {
  489. if (!this.approvedGuides.length) return this.approvedGuides
  490. if (!this.guideSearchQuery) return this.approvedGuides
  491. var q = this.guideSearchQuery.toLowerCase()
  492. return this.approvedGuides.filter(function(g) {
  493. return (g.realName || '').toLowerCase().indexOf(q) !== -1
  494. || (g.nickname || '').toLowerCase().indexOf(q) !== -1
  495. || (g.title || '').toLowerCase().indexOf(q) !== -1
  496. || (g.specialty || '').toLowerCase().indexOf(q) !== -1
  497. })
  498. }
  499. },
  500. created() {
  501. this.loadApprovedGuides()
  502. this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
  503. this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
  504. },
  505. mounted() {
  506. this.loadSupplySystems()
  507. this.loadPredefinedFields()
  508. this.ensureMembershipLevels()
  509. this.loadOnShelfProducts()
  510. if (this.isEdit) {
  511. this.loadProduct(this.$route.query.id)
  512. }
  513. },
  514. methods: {
  515. handleEditorCreated(editor) {
  516. this.editorInstance = editor
  517. },
  518. async loadSupplySystems() {
  519. if (this.isSupplierAdmin) {
  520. try {
  521. const res = await getMySupplySystem()
  522. if (res.data) {
  523. this.supplySystems = [res.data]
  524. this.form.distributionSystemId = res.data.id
  525. this.handleSystemChange(res.data.id)
  526. }
  527. } catch (e) {
  528. console.error(e)
  529. }
  530. return
  531. }
  532. try {
  533. const res = await listAllSupplySystems()
  534. if (res.data) {
  535. this.supplySystems = res.data
  536. }
  537. } catch (e) {
  538. console.error(e)
  539. }
  540. },
  541. handleSystemChange(systemId) {
  542. if (!systemId) return
  543. const system = this.supplySystems.find(s => s.id === systemId)
  544. if (system && system.platformProfitRate != null) {
  545. this.form.profitRate = Math.round(system.platformProfitRate * 10)
  546. }
  547. },
  548. async loadProduct(id) {
  549. this.pageLoading = true
  550. try {
  551. const res = await getProduct(id)
  552. if (res.code === 200 && res.data) {
  553. const p = res.data
  554. this.form = {
  555. id: p.id,
  556. name: p.name || '',
  557. intro: p.intro || '',
  558. description: p.description || '',
  559. coverImage: p.coverImage || '',
  560. images: p.images || '',
  561. productType: p.productType || 'physical',
  562. price: (p.price != null) ? p.price / 100 : null,
  563. memberPrice: (p.memberPrice != null && p.memberPrice > 0) ? p.memberPrice / 100 : null,
  564. stock: (p.stock != null) ? p.stock : null,
  565. minQuantity: p.minQuantity || null,
  566. vendorName: p.vendorName || '',
  567. vendorId: p.vendorId || null,
  568. distributionSystemId: p.distributionSystemId || null,
  569. deliveryMethod: p.deliveryMethod || 1,
  570. memberEligible: p.memberEligible || 'no',
  571. dimensionWeights: [],
  572. assessmentType: p.assessmentType || 'single',
  573. totalSessions: p.totalSessions || 1,
  574. validityDays: p.validityDays || 365,
  575. guideScope: p.guideScope || 'all',
  576. sortOrder: p.sortOrder != null ? p.sortOrder : 0
  577. }
  578. if (p.images) {
  579. try {
  580. this.imageList = JSON.parse(p.images)
  581. } catch (e) {
  582. this.imageList = []
  583. }
  584. }
  585. // Load dimension weights
  586. this.loadDimensionWeights(p.id)
  587. // Load purchase fields config
  588. this.loadPurchaseFields(p.id)
  589. // 赠送会员规则回显
  590. this.loadGiftRule(p.id)
  591. this.loadGiftConfigs(p.id)
  592. // Load growth archive config
  593. this.loadGrowthArchive(p.id)
  594. // Load delivery config (service persons / online slots)
  595. this.loadDeliveryConfig(p.id)
  596. // Load bundle items config(套餐商品)
  597. if (p.productType === 'bundle') {
  598. this.loadBundleItems(p.id)
  599. }
  600. // v-model 已绑定 form.description,editor 内容自动更新
  601. }
  602. } catch (e) {
  603. this.$message.error('加载商品失败')
  604. } finally {
  605. this.pageLoading = false
  606. }
  607. },
  608. async loadDimensionWeights(productId) {
  609. try {
  610. const res = await getDimensionWeights('product', productId)
  611. if (res.code === 200 && res.data) {
  612. this.form.dimensionWeights = res.data.map(w => ({
  613. dimension: w.dimension,
  614. weight: w.weight,
  615. enabled: w.enabled
  616. }))
  617. }
  618. } catch (e) {
  619. // ignore
  620. }
  621. },
  622. async loadPredefinedFields() {
  623. try {
  624. const res = await getPredefinedPurchaseFields()
  625. if (res.code === 200 && Array.isArray(res.data)) {
  626. this.predefinedFields = res.data.map(f => ({
  627. fieldKey: f.fieldKey,
  628. fieldName: f.fieldName,
  629. selected: false,
  630. required: true
  631. }))
  632. }
  633. } catch (e) {
  634. console.warn('加载预定义字段失败', e)
  635. }
  636. },
  637. async loadPurchaseFields(productId) {
  638. try {
  639. const res = await getProductPurchaseFields(productId)
  640. if (res.code === 200 && Array.isArray(res.data)) {
  641. const configs = res.data
  642. this.purchaseFieldsEnabled = configs.length > 0
  643. this.predefinedFields.forEach(pf => {
  644. const match = configs.find(c => c.fieldKey === pf.fieldKey)
  645. pf.selected = !!match
  646. pf.required = match ? match.isRequired === 1 : false
  647. })
  648. }
  649. } catch (e) {
  650. console.warn('加载购买字段配置失败', e)
  651. }
  652. },
  653. async loadGiftRule(productId) {
  654. try {
  655. const lvRes = await listMembershipLevels()
  656. if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
  657. this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
  658. }
  659. const ruleRes = await listProductGiftRules(productId)
  660. if (ruleRes.code === 200 && Array.isArray(ruleRes.data) && ruleRes.data.length > 0) {
  661. const active = ruleRes.data.find(r => r.status === 1)
  662. if (active) {
  663. this.giftRuleEnabled = true
  664. this.giftRuleLevel = active.levelCode
  665. }
  666. }
  667. } catch (e) {
  668. console.warn('加载赠送会员规则失败', e)
  669. }
  670. },
  671. getGiftItems(levelCode) {
  672. return this.giftConfigsByLevel[levelCode] || []
  673. },
  674. getGiftItemKey(item, idx) {
  675. return (item && item.giftType ? item.giftType : 'item') + '-' + idx
  676. },
  677. addGiftItem(levelCode) {
  678. var arr = this.giftConfigsByLevel[levelCode]
  679. if (!arr) {
  680. this.$set(this.giftConfigsByLevel, levelCode, [])
  681. arr = this.giftConfigsByLevel[levelCode]
  682. }
  683. arr.push({ giftType: 'product', giftProductId: null, cfValue: 0, sortOrder: arr.length })
  684. },
  685. removeGiftItem(levelCode, idx) {
  686. var arr = this.giftConfigsByLevel[levelCode]
  687. if (arr) arr.splice(idx, 1)
  688. },
  689. async ensureMembershipLevels() {
  690. if (this.membershipLevels.length === 0) {
  691. try {
  692. const lvRes = await listMembershipLevels()
  693. if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
  694. this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
  695. }
  696. } catch (e) {
  697. console.warn('加载会员等级失败', e)
  698. }
  699. }
  700. },
  701. async loadOnShelfProducts() {
  702. try {
  703. const res = await getProductList({ status: 'on_shelf', page: 1, size: 200 })
  704. if (res.code === 200) {
  705. const data = res.data || {}
  706. this.onShelfProducts = data.records || (Array.isArray(data) ? data : [])
  707. }
  708. } catch (e) {
  709. console.warn('加载上架商品失败', e)
  710. }
  711. },
  712. async loadGiftConfigs(productId) {
  713. try {
  714. await this.ensureMembershipLevels()
  715. await this.loadOnShelfProducts()
  716. // 初始化每等级空数组
  717. var that = this
  718. this.membershipLevels.forEach(function(lv) {
  719. that.$set(that.giftConfigsByLevel, lv.levelCode, [])
  720. })
  721. const res = await getProductGiftList(productId)
  722. if (res.code === 200 && Array.isArray(res.data)) {
  723. res.data.forEach(function(cfg) {
  724. if (cfg && cfg.levelCode) {
  725. that.$set(that.giftConfigsByLevel, cfg.levelCode, (cfg.items || []).map(function(it) {
  726. return {
  727. giftType: it.giftType,
  728. giftProductId: it.giftProductId,
  729. cfValue: it.cfValue || 0,
  730. sortOrder: it.sortOrder || 0
  731. }
  732. }))
  733. }
  734. })
  735. }
  736. } catch (e) {
  737. console.warn('加载赠品配置失败', e)
  738. }
  739. },
  740. async saveGiftConfigs(productId) {
  741. try {
  742. for (var i = 0; i < this.membershipLevels.length; i++) {
  743. var lv = this.membershipLevels[i]
  744. var items = (this.giftConfigsByLevel[lv.levelCode] || []).map(function(it, idx) {
  745. return {
  746. giftType: it.giftType,
  747. giftProductId: it.giftType === 'product' ? it.giftProductId : null,
  748. cfValue: it.giftType === 'cf' ? (it.cfValue || 0) : 0,
  749. sortOrder: idx
  750. }
  751. })
  752. var status = items.length > 0 ? 1 : 0
  753. await saveProductGift({ productId: productId, levelCode: lv.levelCode, status: status, items: items })
  754. }
  755. } catch (e) {
  756. console.warn('保存赠品配置失败', e)
  757. }
  758. },
  759. async loadGrowthArchive(productId) {
  760. try {
  761. const res = await getGrowthArchiveProduct(productId)
  762. if (res.code === 200 && res.data) {
  763. this.growthArchive = {
  764. enabled: res.data.status === 1,
  765. sortOrder: res.data.sortOrder || 0
  766. }
  767. }
  768. } catch (e) {
  769. console.warn('加载成长档案关联配置失败', e)
  770. }
  771. },
  772. async loadDeliveryConfig(productId) {
  773. if (this.form.deliveryMethod === 4) {
  774. try {
  775. const res = await listDeliveryServicePersons(productId)
  776. if (res.code === 200 && Array.isArray(res.data)) {
  777. this.servicePersons = res.data.map(p => ({
  778. userId: p.userId || '',
  779. name: p.name || '',
  780. title: p.title || '',
  781. intro: p.intro || '',
  782. avatar: p.avatar || '',
  783. price: (p.price != null) ? p.price / 100 : null,
  784. originalPrice: (p.originalPrice != null) ? p.originalPrice / 100 : null
  785. }))
  786. }
  787. } catch (e) {
  788. console.warn('加载线上服务者失败', e)
  789. }
  790. } else if (this.form.deliveryMethod === 5) {
  791. try {
  792. const res = await listDeliveryOnlineSlots(productId)
  793. if (res.code === 200 && Array.isArray(res.data)) {
  794. this.onlineSlots = res.data.map(s => ({
  795. startTime: (s.startTime || '').replace('T', ' ').substring(0, 19),
  796. endTime: (s.endTime || '').replace('T', ' ').substring(0, 19),
  797. maxCapacity: s.maxCapacity || 1
  798. }))
  799. }
  800. } catch (e) {
  801. console.warn('加载线上服务时段失败', e)
  802. }
  803. }
  804. },
  805. addServicePerson() {
  806. this.servicePersons.push({ userId: '', name: '', title: '', intro: '', avatar: '', price: null, originalPrice: null })
  807. },
  808. removeServicePerson(idx) {
  809. this.servicePersons.splice(idx, 1)
  810. },
  811. addOnlineSlot() {
  812. this.onlineSlots.push({ startTime: '', endTime: '', maxCapacity: 1 })
  813. },
  814. removeOnlineSlot(idx) {
  815. this.onlineSlots.splice(idx, 1)
  816. },
  817. buildServicePersonPayload() {
  818. if (this.form.deliveryMethod !== 4) return []
  819. return this.servicePersons
  820. .filter(p => p.userId)
  821. .map(p => ({
  822. userId: Number(p.userId),
  823. name: p.name || '',
  824. title: p.title || '',
  825. intro: p.intro || '',
  826. avatar: p.avatar || '',
  827. price: (p.price != null) ? Math.round(p.price * 100) : 0,
  828. originalPrice: (p.originalPrice != null) ? Math.round(p.originalPrice * 100) : null
  829. }))
  830. },
  831. buildOnlineSlotPayload() {
  832. if (this.form.deliveryMethod !== 5) return []
  833. return this.onlineSlots.filter(s => s.startTime && s.endTime).map(s => ({
  834. startTime: s.startTime,
  835. endTime: s.endTime,
  836. maxCapacity: s.maxCapacity || 1
  837. }))
  838. },
  839. async loadApprovedGuides() {
  840. try {
  841. var res = await getApprovedGuides()
  842. if (res.code === 200 && Array.isArray(res.data)) {
  843. this.approvedGuides = res.data
  844. }
  845. } catch (e) {
  846. console.warn('加载服务商列表失败', e)
  847. }
  848. },
  849. onSelectGuide(userId) {
  850. if (!userId) return
  851. var guide = this.approvedGuides.find(function(g) { return g.userId == userId })
  852. if (!guide) return
  853. this.servicePersons.push({
  854. userId: String(userId),
  855. name: guide.realName || guide.nickname || '',
  856. title: guide.title || '',
  857. intro: guide.intro || guide.bio || '',
  858. avatar: guide.avatar || '',
  859. price: null,
  860. originalPrice: null
  861. })
  862. this.selectedGuideId = ''
  863. },
  864. addSelectedGuide() {
  865. this.onSelectGuide(this.selectedGuideId)
  866. },
  867. handlePurchaseFieldsToggle(val) {
  868. if (!val) {
  869. this.predefinedFields.forEach(pf => { pf.selected = false })
  870. }
  871. },
  872. syncPurchaseFieldOrder() {
  873. // sortOrder is derived from selection order
  874. },
  875. buildPurchaseFieldPayload() {
  876. if (!this.purchaseFieldsEnabled) return []
  877. return this.predefinedFields
  878. .filter(pf => pf.selected)
  879. .map((pf, idx) => ({
  880. fieldKey: pf.fieldKey,
  881. fieldName: pf.fieldName,
  882. isRequired: pf.required ? 1 : 0,
  883. sortOrder: idx
  884. }))
  885. },
  886. beforeUpload(file) {
  887. const isImage = file.type.startsWith('image/')
  888. const isLt5M = file.size / 1024 / 1024 < 5
  889. if (!isImage) {
  890. this.$message.error('只能上传图片文件')
  891. return false
  892. }
  893. if (!isLt5M) {
  894. this.$message.error('图片大小不能超过 5MB')
  895. return false
  896. }
  897. return true
  898. },
  899. handleCoverSuccess(res) {
  900. if (res.code === 200 && res.data) {
  901. this.form.coverImage = res.data.url || res.data
  902. this.$message.success('封面上传成功')
  903. } else {
  904. this.$message.error(res.message || '上传失败')
  905. }
  906. },
  907. handleImageSuccess(res) {
  908. if (res.code === 200 && res.data) {
  909. const url = res.data.url || res.data
  910. this.imageList.push(url)
  911. this.form.images = JSON.stringify(this.imageList)
  912. this.$message.success('图片上传成功')
  913. } else {
  914. this.$message.error(res.message || '上传失败')
  915. }
  916. },
  917. removeImage(idx) {
  918. this.imageList.splice(idx, 1)
  919. this.form.images = JSON.stringify(this.imageList)
  920. },
  921. async handleSave() {
  922. this.$refs.form.validate(async (valid) => {
  923. if (!valid) return
  924. this.saving = true
  925. try {
  926. const productData = {
  927. ...this.form,
  928. price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
  929. memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
  930. images: JSON.stringify(this.imageList)
  931. }
  932. // Extract dimensionWeights before sending to product API
  933. const dimensionWeights = productData.dimensionWeights
  934. delete productData.dimensionWeights
  935. // Build ProductCreateRequest payload
  936. const requestPayload = { product: productData }
  937. if (productData.productType === 'assessment') {
  938. requestPayload.assessmentExt = {
  939. assessmentType: productData.assessmentType,
  940. totalSessions: productData.totalSessions,
  941. validityDays: productData.validityDays,
  942. guideScope: productData.guideScope
  943. }
  944. }
  945. // Remove assessment ext fields from product object
  946. delete productData.assessmentType
  947. delete productData.totalSessions
  948. delete productData.validityDays
  949. delete productData.guideScope
  950. let res
  951. let productId = this.form.id
  952. if (this.isEdit) {
  953. res = await updateProduct(requestPayload)
  954. productId = this.form.id
  955. } else {
  956. res = await createProduct(requestPayload)
  957. if (res.code === 200 && res.data) {
  958. productId = res.data.id || res.data
  959. this.form.id = productId
  960. }
  961. }
  962. if (res.code === 200) {
  963. // Save dimension weights
  964. if (productId && Array.isArray(dimensionWeights)) {
  965. try {
  966. await saveDimensionWeights('product', productId, dimensionWeights)
  967. } catch (e) {
  968. console.warn('保存维度权重失败', e)
  969. }
  970. }
  971. // Save purchase fields config
  972. if (productId) {
  973. try {
  974. const fields = this.buildPurchaseFieldPayload()
  975. await saveProductPurchaseFields(productId, fields)
  976. } catch (e) {
  977. console.warn('保存购买字段配置失败', e)
  978. }
  979. }
  980. // 保存赠送会员规则
  981. if (productId) {
  982. try {
  983. await saveProductGiftRule(productId, this.giftRuleEnabled ? 1 : 0,
  984. this.giftRuleEnabled ? this.giftRuleLevel : '')
  985. } catch (e) {
  986. console.warn('保存赠送会员规则失败', e)
  987. }
  988. }
  989. // 保存可选赠品配置
  990. if (productId) {
  991. try {
  992. await this.saveGiftConfigs(productId)
  993. } catch (e) {
  994. console.warn('保存赠品配置失败', e)
  995. }
  996. }
  997. // Save growth archive config (only for existing products)
  998. if (this.form.id) {
  999. try {
  1000. await saveGrowthArchiveProduct({
  1001. productId: this.form.id,
  1002. status: this.growthArchive.enabled ? 1 : 0
  1003. })
  1004. } catch (e) {
  1005. this.$message.error('保存成长档案关联失败')
  1006. console.warn('保存成长档案关联失败', e)
  1007. }
  1008. }
  1009. // Save delivery config (service persons / online slots)
  1010. if (productId) {
  1011. try {
  1012. await saveDeliveryServicePersons(productId, this.buildServicePersonPayload())
  1013. await saveDeliveryOnlineSlots(productId, this.buildOnlineSlotPayload())
  1014. } catch (e) {
  1015. this.$message.error('保存线上服务配置失败:' + (e.message || e))
  1016. console.warn('保存线上服务配置失败', e)
  1017. }
  1018. }
  1019. // Save bundle items config(套餐商品组成)
  1020. if (productId) {
  1021. try {
  1022. var bundlePayload = this.buildBundleItemPayload()
  1023. await saveBundleItems(productId, bundlePayload)
  1024. } catch (e) {
  1025. this.$message.error('保存套餐组成失败')
  1026. console.warn('保存套餐组成失败', e)
  1027. }
  1028. }
  1029. this.$message.success(this.isEdit ? '保存成功' : '创建成功')
  1030. this.$router.push('/product-manage')
  1031. } else {
  1032. this.$message.error(res.message || '操作失败')
  1033. }
  1034. } catch (e) {
  1035. this.$message.error('操作失败')
  1036. } finally {
  1037. this.saving = false
  1038. }
  1039. })
  1040. }
  1041. }
  1042. }
  1043. </script>
  1044. <style scoped>
  1045. .product-edit {
  1046. padding: 20px;
  1047. display: block;
  1048. min-height: auto;
  1049. }
  1050. .product-edit > .el-card {
  1051. display: block;
  1052. min-height: auto;
  1053. }
  1054. .product-edit > .el-card /deep/ .el-card__body {
  1055. display: block;
  1056. min-height: auto;
  1057. overflow: visible;
  1058. }
  1059. .cover-uploader {
  1060. display: flex;
  1061. align-items: center;
  1062. gap: 12px;
  1063. }
  1064. .cover-preview {
  1065. width: 100px;
  1066. height: 100px;
  1067. object-fit: cover;
  1068. border-radius: 4px;
  1069. border: 1px solid #ddd;
  1070. }
  1071. .cover-icon {
  1072. width: 100px;
  1073. height: 100px;
  1074. line-height: 100px;
  1075. text-align: center;
  1076. font-size: 28px;
  1077. color: #8c939d;
  1078. border: 1px dashed #d9d9d9;
  1079. border-radius: 4px;
  1080. display: block;
  1081. cursor: pointer;
  1082. }
  1083. .cover-icon:hover {
  1084. border-color: #409eff;
  1085. color: #409eff;
  1086. }
  1087. .cover-tip {
  1088. font-size: 12px;
  1089. color: #999;
  1090. }
  1091. .images-uploader {
  1092. display: flex;
  1093. flex-wrap: wrap;
  1094. gap: 8px;
  1095. }
  1096. .image-item {
  1097. position: relative;
  1098. width: 80px;
  1099. height: 80px;
  1100. }
  1101. .image-preview {
  1102. width: 80px;
  1103. height: 80px;
  1104. object-fit: cover;
  1105. border-radius: 4px;
  1106. border: 1px solid #ddd;
  1107. }
  1108. .image-remove {
  1109. position: absolute;
  1110. top: -6px;
  1111. right: -6px;
  1112. width: 18px;
  1113. height: 18px;
  1114. background: #f56c6c;
  1115. color: #fff;
  1116. border-radius: 50%;
  1117. text-align: center;
  1118. line-height: 18px;
  1119. font-size: 12px;
  1120. cursor: pointer;
  1121. }
  1122. .image-add-icon {
  1123. width: 80px;
  1124. height: 80px;
  1125. line-height: 80px;
  1126. text-align: center;
  1127. font-size: 24px;
  1128. color: #8c939d;
  1129. border: 1px dashed #d9d9d9;
  1130. border-radius: 4px;
  1131. display: block;
  1132. cursor: pointer;
  1133. }
  1134. .image-add-icon:hover {
  1135. border-color: #409eff;
  1136. color: #409eff;
  1137. }
  1138. .product-edit .el-card {
  1139. width: 100%;
  1140. }
  1141. .product-edit .el-form {
  1142. width: 100%;
  1143. }
  1144. .rich-editor {
  1145. border: 1px solid #dcdfe6;
  1146. border-radius: 4px;
  1147. }
  1148. .rich-editor /deep/ .w-e-toolbar {
  1149. flex-wrap: wrap;
  1150. }
  1151. .rich-editor /deep/ .w-e-text-container {
  1152. min-height: 300px;
  1153. max-height: 500px;
  1154. overflow-y: auto;
  1155. }
  1156. .purchase-field-section {
  1157. display: flex;
  1158. flex-direction: column;
  1159. gap: 12px;
  1160. }
  1161. .purchase-field-list {
  1162. display: flex;
  1163. flex-wrap: wrap;
  1164. gap: 8px;
  1165. padding: 12px;
  1166. background: #fafafa;
  1167. border-radius: 4px;
  1168. border: 1px solid #eee;
  1169. }
  1170. .purchase-field-item {
  1171. display: flex;
  1172. align-items: center;
  1173. gap: 8px;
  1174. padding: 6px 12px;
  1175. background: #fff;
  1176. border: 1px solid #e8e8e8;
  1177. border-radius: 4px;
  1178. }
  1179. .purchase-field-required {
  1180. font-size: 12px;
  1181. }
  1182. .purchase-field-drag {
  1183. color: #ccc;
  1184. cursor: move;
  1185. }
  1186. .gift-config-level {
  1187. margin-bottom: 12px;
  1188. padding: 8px 0;
  1189. border-bottom: 1px dashed #eee;
  1190. }
  1191. .gift-config-level-title {
  1192. font-size: 13px;
  1193. color: #606266;
  1194. margin-bottom: 6px;
  1195. }
  1196. .gift-config-item {
  1197. display: flex;
  1198. align-items: center;
  1199. margin-bottom: 6px;
  1200. }
  1201. .service-person-card {
  1202. border: 1px solid #e8e8e8;
  1203. border-radius: 4px;
  1204. padding: 10px;
  1205. margin-bottom: 10px;
  1206. background: #fafafa;
  1207. }
  1208. .sp-header {
  1209. display: flex;
  1210. justify-content: space-between;
  1211. align-items: center;
  1212. margin-bottom: 8px;
  1213. }
  1214. .sp-title {
  1215. font-weight: 600;
  1216. font-size: 13px;
  1217. }
  1218. .sp-slots {
  1219. margin-top: 8px;
  1220. padding: 8px;
  1221. background: #fff;
  1222. border-radius: 4px;
  1223. }
  1224. .sp-slot-row {
  1225. display: flex;
  1226. align-items: center;
  1227. margin-bottom: 6px;
  1228. }
  1229. .delivery-config-hint {
  1230. margin-top: 6px;
  1231. font-size: 12px;
  1232. color: #999;
  1233. }
  1234. </style>