ProductEdit.vue 42 KB

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