addGoods.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. <template>
  2. <div class="store-page">
  3. <Breadcrumb />
  4. <a-card :bordered="false" class="page-card" :title="isEdit ? '编辑商品' : '新增商品'">
  5. <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
  6. <a-row :gutter="24">
  7. <a-col :xs="24" :md="12">
  8. <a-form-item field="name" label="商品名称">
  9. <a-input v-model="form.name" :max-length="25" allow-clear show-word-limit />
  10. </a-form-item>
  11. </a-col>
  12. <a-col :xs="24" :md="12">
  13. <a-form-item field="state" label="上架状态">
  14. <a-radio-group v-model="form.state" type="button">
  15. <a-radio :value="1">上架</a-radio>
  16. <a-radio :value="0">下架</a-radio>
  17. </a-radio-group>
  18. </a-form-item>
  19. </a-col>
  20. <a-col :xs="24">
  21. <a-form-item field="description" label="商品简介">
  22. <a-textarea
  23. v-model="form.description"
  24. placeholder="展示在商城列表卡片上,建议 1~2 句话"
  25. :max-length="80"
  26. show-word-limit
  27. :auto-size="{ minRows: 2, maxRows: 3 }"
  28. />
  29. </a-form-item>
  30. </a-col>
  31. <a-col :xs="24" :sm="8">
  32. <a-form-item field="price" label="价格(元)">
  33. <a-input-number v-model="form.price" :step="0.01" :precision="2" :min="0" style="width: 100%" />
  34. </a-form-item>
  35. </a-col>
  36. <a-col :xs="24" :sm="8">
  37. <a-form-item field="lepao_count" label="乐跑次数">
  38. <a-input-number v-model="form.lepao_count" :step="1" :precision="0" :min="0" style="width: 100%" />
  39. </a-form-item>
  40. </a-col>
  41. <a-col :xs="24" :sm="8">
  42. <a-form-item field="num" label="库存">
  43. <a-input-number v-model="form.num" :step="1" :precision="0" :min="0" style="width: 100%" />
  44. </a-form-item>
  45. </a-col>
  46. <a-col :xs="24">
  47. <a-form-item field="icon" label="商品图标(Emoji)">
  48. <div class="emoji-panel">
  49. <div class="emoji-panel__preview">
  50. <span class="emoji-panel__emoji">{{ displayEmoji }}</span>
  51. <span class="emoji-panel__hint">当前图标</span>
  52. </div>
  53. <div class="emoji-panel__main">
  54. <div class="emoji-panel__label">快速选择</div>
  55. <div class="emoji-grid">
  56. <button
  57. v-for="emoji in presetEmojis"
  58. :key="emoji"
  59. type="button"
  60. class="emoji-grid__item"
  61. :class="{ 'emoji-grid__item--active': form.icon === emoji }"
  62. :title="emoji"
  63. @click="form.icon = emoji"
  64. >
  65. {{ emoji }}
  66. </button>
  67. </div>
  68. <div class="emoji-panel__label emoji-panel__label--custom">自定义</div>
  69. <a-input
  70. v-model="form.icon"
  71. class="emoji-custom-input"
  72. placeholder="输入或粘贴一个 emoji"
  73. :max-length="8"
  74. allow-clear
  75. />
  76. </div>
  77. </div>
  78. </a-form-item>
  79. </a-col>
  80. <a-col :xs="24">
  81. <a-form-item label="商品卖点">
  82. <div class="features-editor">
  83. <p class="features-editor__tip">展示在商城列表与详情页,最多 6 条</p>
  84. <div v-for="(_, index) in featureItems" :key="index" class="features-editor__row">
  85. <span class="features-editor__index">{{ index + 1 }}</span>
  86. <a-input
  87. v-model="featureItems[index]"
  88. placeholder="例如:支付后自动到账"
  89. :max-length="40"
  90. allow-clear
  91. />
  92. <a-button
  93. type="text"
  94. status="danger"
  95. :disabled="featureItems.length <= 1"
  96. @click="removeFeature(index)"
  97. >
  98. <template #icon><icon-delete /></template>
  99. </a-button>
  100. </div>
  101. <a-button
  102. type="dashed"
  103. long
  104. class="features-editor__add"
  105. :disabled="featureItems.length >= 6"
  106. @click="addFeature"
  107. >
  108. <template #icon><icon-plus /></template>
  109. 添加卖点
  110. </a-button>
  111. </div>
  112. </a-form-item>
  113. </a-col>
  114. </a-row>
  115. <a-form-item field="content" label="商品详情(展示在商城详情页)">
  116. <WangEditor v-model="form.content" @change="contentChange" />
  117. </a-form-item>
  118. <a-form-item>
  119. <a-space>
  120. <a-button type="primary" html-type="submit" :loading="loading">保存商品</a-button>
  121. <a-button @click="$router.push('/goodsManage/goods/goodsList')">返回列表</a-button>
  122. </a-space>
  123. </a-form-item>
  124. </a-form>
  125. </a-card>
  126. </div>
  127. </template>
  128. <script setup>
  129. import { ref, reactive, computed, onMounted } from 'vue'
  130. import { adminGetGoods, addGoods } from '@/api/goods'
  131. import { useRoute } from 'vue-router'
  132. import { Notification } from '@arco-design/web-vue'
  133. import WangEditor from '@/components/Editor/WangEditor.vue'
  134. import {
  135. decodeGoodsContent,
  136. DEFAULT_GOODS_EMOJI,
  137. getGoodsEmoji,
  138. parseFeatures,
  139. serializeFeatures
  140. } from '@/utils/storeFormat'
  141. const presetEmojis = ['🏃', '⚡', '🎁', '🔥', '💪', '🏆', '📦', '⭐', '🎯', '✨']
  142. const loading = ref(false)
  143. const route = useRoute()
  144. const isEdit = computed(() => Boolean(route.params.id))
  145. const featureItems = ref([''])
  146. const displayEmoji = computed(() => getGoodsEmoji(form.icon))
  147. const rules = {
  148. name: [{ required: true, message: '请输入商品名称' }],
  149. price: [{ required: true, message: '请填写商品价格' }],
  150. lepao_count: [{ required: true, message: '请填写乐跑次数' }],
  151. num: [{ required: true, message: '请填写商品库存' }],
  152. state: [{ required: true, message: '请选择商品状态' }]
  153. }
  154. const form = reactive({
  155. name: '',
  156. description: '',
  157. price: 0.0,
  158. num: 999999,
  159. lepao_count: 0,
  160. ic_count: 0,
  161. content: '',
  162. state: 1,
  163. icon: DEFAULT_GOODS_EMOJI
  164. })
  165. const addFeature = () => {
  166. if (featureItems.value.length < 6) featureItems.value.push('')
  167. }
  168. const removeFeature = (index) => {
  169. if (featureItems.value.length <= 1) {
  170. featureItems.value[0] = ''
  171. return
  172. }
  173. featureItems.value.splice(index, 1)
  174. }
  175. const contentChange = (value) => {
  176. form.content = value
  177. }
  178. const handleSubmit = async () => {
  179. try {
  180. loading.value = true
  181. const data = {
  182. ...form,
  183. id: route.params.id ?? null,
  184. icon: getGoodsEmoji(form.icon),
  185. features: serializeFeatures(featureItems.value)
  186. }
  187. data.content = btoa(encodeURI(data.content))
  188. const res = await addGoods(data)
  189. if (!res || res.code !== 0) {
  190. return Notification.error({
  191. title: '保存商品失败!',
  192. content: res?.msg ?? '请稍后再试'
  193. })
  194. }
  195. Notification.success({ title: '保存成功!' })
  196. } catch (error) {
  197. Notification.error({
  198. title: '保存商品失败!',
  199. content: error.message || '请稍后再试'
  200. })
  201. } finally {
  202. loading.value = false
  203. }
  204. }
  205. const getGoodsDetail = async () => {
  206. if (!route.params.id) return
  207. try {
  208. loading.value = true
  209. const res = await adminGetGoods({ id: route.params.id })
  210. if (!res || res.code !== 0) {
  211. return Notification.error({
  212. title: '获取商品信息失败!',
  213. content: res?.msg ?? '请稍后再试'
  214. })
  215. }
  216. const { name, price, num, content, state, lepao_count, icon, description, features } = res.data
  217. form.content = decodeGoodsContent(content)
  218. form.name = name
  219. form.description = description || ''
  220. form.price = Number(price)
  221. form.lepao_count = lepao_count
  222. form.num = num
  223. form.state = state
  224. form.icon = getGoodsEmoji(icon)
  225. const parsed = parseFeatures(features)
  226. featureItems.value = parsed.length ? [...parsed] : ['']
  227. } catch (error) {
  228. Notification.error({
  229. title: '获取商品信息失败!',
  230. content: error.message || '请稍后再试'
  231. })
  232. } finally {
  233. loading.value = false
  234. }
  235. }
  236. onMounted(() => {
  237. getGoodsDetail()
  238. })
  239. </script>
  240. <style scoped lang="less">
  241. .page-card {
  242. border-radius: 12px;
  243. }
  244. .emoji-panel {
  245. display: flex;
  246. gap: 24px;
  247. padding: 20px;
  248. background: var(--color-fill-1);
  249. border: 1px solid var(--color-border-2);
  250. border-radius: 12px;
  251. align-items: stretch;
  252. @media (max-width: 640px) {
  253. flex-direction: column;
  254. }
  255. &__preview {
  256. flex-shrink: 0;
  257. width: 120px;
  258. display: flex;
  259. flex-direction: column;
  260. align-items: center;
  261. justify-content: center;
  262. gap: 8px;
  263. background: var(--color-bg-2);
  264. border-radius: 12px;
  265. border: 1px solid var(--color-border-2);
  266. }
  267. &__emoji {
  268. font-size: 3.5rem;
  269. line-height: 1;
  270. }
  271. &__hint {
  272. font-size: 12px;
  273. color: var(--color-text-3);
  274. }
  275. &__main {
  276. flex: 1;
  277. min-width: 0;
  278. display: flex;
  279. flex-direction: column;
  280. gap: 12px;
  281. }
  282. &__label {
  283. font-size: 13px;
  284. font-weight: 500;
  285. color: var(--color-text-2);
  286. &--custom {
  287. margin-top: 4px;
  288. }
  289. }
  290. }
  291. .emoji-grid {
  292. display: grid;
  293. grid-template-columns: repeat(5, 52px);
  294. gap: 10px;
  295. @media (max-width: 480px) {
  296. grid-template-columns: repeat(5, 1fr);
  297. }
  298. &__item {
  299. width: 52px;
  300. height: 52px;
  301. padding: 0;
  302. font-size: 26px;
  303. line-height: 1;
  304. border: 2px solid var(--color-border-2);
  305. border-radius: 10px;
  306. background: var(--color-bg-2);
  307. cursor: pointer;
  308. transition: border-color 0.2s, background 0.2s, transform 0.15s;
  309. display: flex;
  310. align-items: center;
  311. justify-content: center;
  312. @media (max-width: 480px) {
  313. width: 100%;
  314. aspect-ratio: 1;
  315. height: auto;
  316. }
  317. &:hover {
  318. transform: scale(1.06);
  319. border-color: rgb(var(--primary-5));
  320. }
  321. &--active {
  322. border-color: rgb(var(--primary-6));
  323. background: var(--color-primary-light-1);
  324. box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.15);
  325. }
  326. }
  327. }
  328. .emoji-custom-input {
  329. max-width: 320px;
  330. height: 40px;
  331. }
  332. .features-editor {
  333. padding: 16px;
  334. background: var(--color-fill-1);
  335. border: 1px solid var(--color-border-2);
  336. border-radius: 12px;
  337. &__tip {
  338. margin: 0 0 12px;
  339. font-size: 12px;
  340. color: var(--color-text-3);
  341. }
  342. &__row {
  343. display: flex;
  344. align-items: center;
  345. gap: 8px;
  346. margin-bottom: 10px;
  347. }
  348. &__index {
  349. flex-shrink: 0;
  350. width: 22px;
  351. height: 22px;
  352. border-radius: 50%;
  353. background: var(--color-fill-3);
  354. color: var(--color-text-2);
  355. font-size: 12px;
  356. display: flex;
  357. align-items: center;
  358. justify-content: center;
  359. }
  360. &__add {
  361. margin-top: 4px;
  362. }
  363. }
  364. </style>