PointsProductList.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. <template>
  2. <div class="points-product-container">
  3. <h2 class="page-title">积分商品管理</h2>
  4. <!-- 工具栏 -->
  5. <el-card shadow="never" class="toolbar-card">
  6. <el-row :gutter="20">
  7. <el-col :span="12">
  8. <el-button type="primary" icon="Plus" @click="showCreateDialog">新增商品</el-button>
  9. <el-button icon="Refresh" @click="loadList">刷新</el-button>
  10. </el-col>
  11. <el-col :span="12">
  12. <el-space wrap style="float: right">
  13. <el-select v-model="filters.category" placeholder="商品类型" clearable style="width: 120px" @change="loadList">
  14. <el-option label="全部" :value="undefined" />
  15. <el-option label="实物" :value="1" />
  16. <el-option label="虚拟" :value="2" />
  17. </el-select>
  18. <el-select v-model="filters.status" placeholder="状态" clearable style="width: 120px" @change="loadList">
  19. <el-option label="全部" :value="undefined" />
  20. <el-option label="上架" :value="1" />
  21. <el-option label="下架" :value="0" />
  22. </el-select>
  23. <el-input v-model="filters.keyword" placeholder="搜索商品名称" clearable style="width: 200px" @keyup.enter="loadList">
  24. <template #append>
  25. <el-button icon="Search" @click="loadList" />
  26. </template>
  27. </el-input>
  28. </el-space>
  29. </el-col>
  30. </el-row>
  31. </el-card>
  32. <!-- 商品列表 -->
  33. <el-card shadow="never" class="table-card">
  34. <el-table
  35. v-loading="loading"
  36. :data="list"
  37. stripe
  38. style="width: 100%"
  39. >
  40. <template #empty>
  41. <el-empty description="暂无商品数据" />
  42. </template>
  43. <el-table-column type="index" label="序号" width="60" />
  44. <el-table-column label="商品图片" width="120">
  45. <template #default="{ row }">
  46. <el-image
  47. v-if="row.imageUrl"
  48. :src="row.imageUrl"
  49. fit="cover"
  50. style="width: 80px; height: 80px; border-radius: 6px; cursor: pointer;"
  51. :preview-src-list="[row.imageUrl]"
  52. />
  53. <span v-else style="color: #999;">暂无图片</span>
  54. </template>
  55. </el-table-column>
  56. <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
  57. <el-table-column prop="description" label="商品描述" min-width="200" show-overflow-tooltip />
  58. <el-table-column prop="pointsPrice" label="积分价格" width="100" align="center">
  59. <template #default="{ row }">
  60. <span style="color: #E91E63; font-weight: bold;">{{ row.pointsPrice }}</span>
  61. </template>
  62. </el-table-column>
  63. <el-table-column prop="stock" label="库存" width="80" align="center">
  64. <template #default="{ row }">
  65. <el-tag :type="row.stock > 0 ? 'success' : 'danger'" size="small">
  66. {{ row.stock }}
  67. </el-tag>
  68. </template>
  69. </el-table-column>
  70. <el-table-column prop="category" label="类型" width="100" align="center">
  71. <template #default="{ row }">
  72. <el-tag :type="row.category === 1 ? 'primary' : 'warning'" size="small">
  73. {{ row.category === 1 ? '实物' : '虚拟' }}
  74. </el-tag>
  75. </template>
  76. </el-table-column>
  77. <el-table-column prop="isRecommend" label="推荐" width="80" align="center">
  78. <template #default="{ row }">
  79. <el-tag v-if="row.isRecommend === 1" type="danger" size="small">推荐</el-tag>
  80. <span v-else style="color: #999;">-</span>
  81. </template>
  82. </el-table-column>
  83. <el-table-column prop="status" label="状态" width="100" align="center">
  84. <template #default="{ row }">
  85. <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
  86. {{ row.status === 1 ? '上架' : '下架' }}
  87. </el-tag>
  88. </template>
  89. </el-table-column>
  90. <el-table-column prop="sortOrder" label="排序" width="80" align="center" />
  91. <el-table-column label="操作" width="280" fixed="right">
  92. <template #default="{ row }">
  93. <el-space>
  94. <el-button
  95. type="info"
  96. size="small"
  97. link
  98. @click="handleViewDetail(row)"
  99. >
  100. <el-icon><View /></el-icon>
  101. 详情
  102. </el-button>
  103. <el-button type="primary" size="small" link @click="showEditDialog(row)">
  104. <el-icon><Edit /></el-icon>
  105. 编辑
  106. </el-button>
  107. <el-button
  108. v-if="row.status === 0"
  109. type="success"
  110. size="small"
  111. link
  112. @click="handleOnShelf(row)"
  113. >
  114. <el-icon><Top /></el-icon>
  115. 上架
  116. </el-button>
  117. <el-button
  118. v-if="row.status === 1"
  119. type="warning"
  120. size="small"
  121. link
  122. @click="handleOffShelf(row)"
  123. >
  124. <el-icon><Bottom /></el-icon>
  125. 下架
  126. </el-button>
  127. <el-button type="danger" size="small" link @click="handleDelete(row)">
  128. <el-icon><Delete /></el-icon>
  129. 删除
  130. </el-button>
  131. </el-space>
  132. </template>
  133. </el-table-column>
  134. </el-table>
  135. <!-- 分页 -->
  136. <div class="pagination-container" v-if="list.length > 0">
  137. <el-pagination
  138. v-model:current-page="currentPage"
  139. v-model:page-size="pageSize"
  140. :total="total"
  141. :page-sizes="[10, 20, 50, 100]"
  142. layout="total, sizes, prev, pager, next, jumper"
  143. @size-change="loadList"
  144. @current-change="loadList"
  145. />
  146. </div>
  147. </el-card>
  148. <!-- 创建/编辑对话框 -->
  149. <el-dialog
  150. v-model="dialogVisible"
  151. :title="isEdit ? '编辑商品' : '新增商品'"
  152. width="700px"
  153. @close="resetForm"
  154. >
  155. <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
  156. <el-form-item label="商品名称" prop="name">
  157. <el-input v-model="form.name" placeholder="请输入商品名称" />
  158. </el-form-item>
  159. <el-form-item label="商品描述" prop="description">
  160. <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入商品描述" />
  161. </el-form-item>
  162. <el-form-item label="商品图片" prop="imageUrl">
  163. <el-upload
  164. class="image-uploader"
  165. action="#"
  166. :show-file-list="false"
  167. :before-upload="handleBeforeUpload"
  168. :http-request="handleUpload"
  169. >
  170. <el-image
  171. v-if="form.imageUrl"
  172. :src="form.imageUrl"
  173. class="uploaded-image"
  174. fit="cover"
  175. />
  176. <el-icon v-else class="uploader-icon"><Plus /></el-icon>
  177. </el-upload>
  178. <div class="upload-tip">支持 JPG、PNG 格式,大小不超过 10MB</div>
  179. </el-form-item>
  180. <el-row :gutter="20">
  181. <el-col :span="12">
  182. <el-form-item label="积分价格" prop="pointsPrice">
  183. <el-input-number v-model="form.pointsPrice" :min="1" :precision="0" style="width: 100%" />
  184. </el-form-item>
  185. </el-col>
  186. <el-col :span="12">
  187. <el-form-item label="库存" prop="stock">
  188. <el-input-number v-model="form.stock" :min="0" :precision="0" style="width: 100%" />
  189. </el-form-item>
  190. </el-col>
  191. </el-row>
  192. <el-row :gutter="20">
  193. <el-col :span="12">
  194. <el-form-item label="商品类型" prop="category">
  195. <el-select v-model="form.category" placeholder="请选择商品类型" style="width: 100%">
  196. <el-option label="实物" :value="1" />
  197. <el-option label="虚拟" :value="2" />
  198. </el-select>
  199. </el-form-item>
  200. </el-col>
  201. <el-col :span="12">
  202. <el-form-item label="排序" prop="sortOrder">
  203. <el-input-number v-model="form.sortOrder" :min="0" :precision="0" style="width: 100%" />
  204. </el-form-item>
  205. </el-col>
  206. </el-row>
  207. <el-row :gutter="20">
  208. <el-col :span="12">
  209. <el-form-item label="是否推荐" prop="isRecommend">
  210. <el-switch v-model="form.isRecommend" :active-value="1" :inactive-value="0" />
  211. </el-form-item>
  212. </el-col>
  213. <el-col :span="12">
  214. <el-form-item label="状态" prop="status">
  215. <el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="上架" inactive-text="下架" />
  216. </el-form-item>
  217. </el-col>
  218. </el-row>
  219. </el-form>
  220. <template #footer>
  221. <el-button @click="dialogVisible = false">取消</el-button>
  222. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
  223. </template>
  224. </el-dialog>
  225. <!-- 详情对话框 -->
  226. <el-dialog
  227. v-model="detailDialogVisible"
  228. title="商品详情"
  229. width="800px"
  230. :close-on-click-modal="false"
  231. >
  232. <div v-if="detailData" class="detail-content">
  233. <!-- 基本信息 -->
  234. <el-card shadow="never" class="detail-section">
  235. <template #header>
  236. <div class="section-header">
  237. <el-icon><InfoFilled /></el-icon>
  238. <span>基本信息</span>
  239. </div>
  240. </template>
  241. <el-descriptions :column="2" border>
  242. <el-descriptions-item label="商品ID">
  243. {{ detailData.id || '-' }}
  244. </el-descriptions-item>
  245. <el-descriptions-item label="商品名称">
  246. {{ detailData.name || '-' }}
  247. </el-descriptions-item>
  248. <el-descriptions-item label="商品描述" :span="2">
  249. <div class="description-content">
  250. {{ detailData.description || '-' }}
  251. </div>
  252. </el-descriptions-item>
  253. <el-descriptions-item label="商品图片" :span="2">
  254. <el-image
  255. v-if="detailData.imageUrl"
  256. :src="detailData.imageUrl"
  257. fit="cover"
  258. style="width: 200px; height: 200px; border-radius: 6px;"
  259. :preview-src-list="[detailData.imageUrl]"
  260. />
  261. <span v-else style="color: #999;">暂无图片</span>
  262. </el-descriptions-item>
  263. </el-descriptions>
  264. </el-card>
  265. <!-- 价格与库存 -->
  266. <el-card shadow="never" class="detail-section">
  267. <template #header>
  268. <div class="section-header">
  269. <el-icon><Money /></el-icon>
  270. <span>价格与库存</span>
  271. </div>
  272. </template>
  273. <el-descriptions :column="2" border>
  274. <el-descriptions-item label="积分价格">
  275. <span style="color: #E91E63; font-weight: bold; font-size: 16px;">
  276. {{ detailData.pointsPrice || 0 }}
  277. </span>
  278. </el-descriptions-item>
  279. <el-descriptions-item label="库存">
  280. <el-tag :type="detailData.stock > 0 ? 'success' : 'danger'" size="small">
  281. {{ detailData.stock || 0 }}
  282. </el-tag>
  283. </el-descriptions-item>
  284. </el-descriptions>
  285. </el-card>
  286. <!-- 商品属性 -->
  287. <el-card shadow="never" class="detail-section">
  288. <template #header>
  289. <div class="section-header">
  290. <el-icon><Setting /></el-icon>
  291. <span>商品属性</span>
  292. </div>
  293. </template>
  294. <el-descriptions :column="2" border>
  295. <el-descriptions-item label="商品类型">
  296. <el-tag :type="detailData.category === 1 ? 'primary' : 'warning'" size="small">
  297. {{ detailData.category === 1 ? '实物' : '虚拟' }}
  298. </el-tag>
  299. </el-descriptions-item>
  300. <el-descriptions-item label="是否推荐">
  301. <el-tag v-if="detailData.isRecommend === 1" type="danger" size="small">推荐</el-tag>
  302. <span v-else style="color: #999;">否</span>
  303. </el-descriptions-item>
  304. <el-descriptions-item label="状态">
  305. <el-tag :type="detailData.status === 1 ? 'success' : 'info'" size="small">
  306. {{ detailData.status === 1 ? '上架' : '下架' }}
  307. </el-tag>
  308. </el-descriptions-item>
  309. <el-descriptions-item label="排序">
  310. {{ detailData.sortOrder || 0 }}
  311. </el-descriptions-item>
  312. </el-descriptions>
  313. </el-card>
  314. </div>
  315. </el-dialog>
  316. </div>
  317. </template>
  318. <script setup>
  319. import { ref, reactive, onMounted } from 'vue'
  320. import { ElMessage, ElMessageBox } from 'element-plus'
  321. import { Plus, Search, Refresh, Edit, Delete, InfoFilled, Money, Setting, View, Top, Bottom } from '@element-plus/icons-vue'
  322. import request from '@/utils/request'
  323. import { API_ENDPOINTS } from '@/config/api'
  324. const loading = ref(false)
  325. const dialogVisible = ref(false)
  326. const isEdit = ref(false)
  327. const submitLoading = ref(false)
  328. const formRef = ref(null)
  329. const currentPage = ref(1)
  330. const pageSize = ref(10)
  331. const total = ref(0)
  332. const list = ref([])
  333. const detailDialogVisible = ref(false)
  334. const detailData = ref(null)
  335. const filters = reactive({
  336. status: null,
  337. category: null,
  338. keyword: ''
  339. })
  340. const form = reactive({
  341. id: null,
  342. name: '',
  343. description: '',
  344. imageUrl: '',
  345. pointsPrice: 0,
  346. stock: 0,
  347. category: 1,
  348. isRecommend: 0,
  349. status: 0,
  350. sortOrder: 0
  351. })
  352. const rules = {
  353. name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  354. pointsPrice: [{ required: true, message: '请输入积分价格', trigger: 'blur' }],
  355. category: [{ required: true, message: '请选择商品类型', trigger: 'change' }]
  356. }
  357. const loadList = async () => {
  358. loading.value = true
  359. try {
  360. const params = {
  361. page: currentPage.value,
  362. size: pageSize.value
  363. }
  364. if (filters.status !== null && filters.status !== undefined) params.status = filters.status
  365. if (filters.category !== null && filters.category !== undefined) params.category = filters.category
  366. if (filters.keyword) params.keyword = filters.keyword
  367. const response = await request.get('/admin/points-product/list', { params })
  368. if (response.code === 200) {
  369. list.value = response.data.records || []
  370. total.value = response.data.total || 0
  371. }
  372. } catch (error) {
  373. console.error('加载失败:', error)
  374. ElMessage.error('加载失败')
  375. } finally {
  376. loading.value = false
  377. }
  378. }
  379. const showCreateDialog = () => {
  380. isEdit.value = false
  381. resetForm()
  382. dialogVisible.value = true
  383. }
  384. const showEditDialog = (row) => {
  385. isEdit.value = true
  386. Object.assign(form, {
  387. id: row.id,
  388. name: row.name,
  389. description: row.description || '',
  390. imageUrl: row.imageUrl || '',
  391. pointsPrice: row.pointsPrice,
  392. stock: row.stock || 0,
  393. category: row.category,
  394. isRecommend: row.isRecommend || 0,
  395. status: row.status,
  396. sortOrder: row.sortOrder || 0
  397. })
  398. dialogVisible.value = true
  399. }
  400. const resetForm = () => {
  401. if (formRef.value) {
  402. formRef.value.resetFields()
  403. }
  404. form.id = null
  405. form.name = ''
  406. form.description = ''
  407. form.imageUrl = ''
  408. form.pointsPrice = 0
  409. form.stock = 0
  410. form.category = 1
  411. form.isRecommend = 0
  412. form.status = 0
  413. form.sortOrder = 0
  414. }
  415. const handleSubmit = async () => {
  416. if (!formRef.value) return
  417. try {
  418. await formRef.value.validate()
  419. submitLoading.value = true
  420. const url = isEdit.value ? '/admin/points-product/update' : '/admin/points-product/create'
  421. const method = isEdit.value ? 'put' : 'post'
  422. const response = await request[method](url, form)
  423. if (response.code === 200) {
  424. ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
  425. dialogVisible.value = false
  426. loadList()
  427. } else {
  428. ElMessage.error(response.msg || '操作失败')
  429. }
  430. } catch (error) {
  431. console.error('提交失败:', error)
  432. if (error !== 'cancel') {
  433. ElMessage.error('操作失败')
  434. }
  435. } finally {
  436. submitLoading.value = false
  437. }
  438. }
  439. const handleOnShelf = async (row) => {
  440. try {
  441. const response = await request.put(`/admin/points-product/on-shelf/${row.id}`)
  442. if (response.code === 200) {
  443. ElMessage.success('上架成功')
  444. loadList()
  445. } else {
  446. ElMessage.error(response.msg || '上架失败')
  447. }
  448. } catch (error) {
  449. console.error('上架失败:', error)
  450. ElMessage.error('上架失败')
  451. }
  452. }
  453. const handleOffShelf = async (row) => {
  454. try {
  455. const response = await request.put(`/admin/points-product/off-shelf/${row.id}`)
  456. if (response.code === 200) {
  457. ElMessage.success('下架成功')
  458. loadList()
  459. } else {
  460. ElMessage.error(response.msg || '下架失败')
  461. }
  462. } catch (error) {
  463. console.error('下架失败:', error)
  464. ElMessage.error('下架失败')
  465. }
  466. }
  467. // 查看详情
  468. const handleViewDetail = (row) => {
  469. detailData.value = { ...row }
  470. detailDialogVisible.value = true
  471. }
  472. const handleDelete = async (row) => {
  473. try {
  474. await ElMessageBox.confirm('确定要删除这个商品吗?', '提示', {
  475. confirmButtonText: '确定',
  476. cancelButtonText: '取消',
  477. type: 'warning'
  478. })
  479. const response = await request.delete(`/admin/points-product/delete/${row.id}`)
  480. if (response.code === 200) {
  481. ElMessage.success('删除成功')
  482. loadList()
  483. } else {
  484. ElMessage.error(response.msg || '删除失败')
  485. }
  486. } catch (error) {
  487. if (error !== 'cancel') {
  488. console.error('删除失败:', error)
  489. }
  490. }
  491. }
  492. // 图片上传相关
  493. const handleBeforeUpload = (file) => {
  494. const isImage = file.type.startsWith('image/')
  495. const isLt10M = file.size / 1024 / 1024 < 10
  496. if (!isImage) {
  497. ElMessage.error('只能上传图片文件!')
  498. return false
  499. }
  500. if (!isLt10M) {
  501. ElMessage.error('图片大小不能超过 10MB!')
  502. return false
  503. }
  504. return true
  505. }
  506. // 上传图片
  507. const handleUpload = async (options) => {
  508. const formData = new FormData()
  509. formData.append('file', options.file)
  510. try {
  511. const response = await request.post(API_ENDPOINTS.UPLOAD_IMAGE, formData, {
  512. headers: { 'Content-Type': 'multipart/form-data' }
  513. })
  514. if (response.code === 200) {
  515. // 处理不同的返回格式
  516. let imageUrl = ''
  517. if (typeof response.data === 'string') {
  518. imageUrl = response.data
  519. } else if (response.data && response.data.url) {
  520. imageUrl = response.data.url
  521. } else if (response.data && response.data.path) {
  522. imageUrl = response.data.path
  523. } else {
  524. imageUrl = String(response.data || '')
  525. }
  526. form.imageUrl = imageUrl
  527. ElMessage.success('图片上传成功')
  528. } else {
  529. ElMessage.error(response.message || '图片上传失败')
  530. }
  531. } catch (error) {
  532. console.error('上传异常:', error)
  533. ElMessage.error('图片上传失败,请重试')
  534. }
  535. }
  536. onMounted(() => loadList())
  537. </script>
  538. <style scoped>
  539. .points-product-container {
  540. padding: 0;
  541. }
  542. .page-title {
  543. font-size: 24px;
  544. font-weight: bold;
  545. color: #333;
  546. margin: 0 0 20px 0;
  547. }
  548. .toolbar-card {
  549. margin-bottom: 20px;
  550. }
  551. .table-card {
  552. margin-bottom: 20px;
  553. }
  554. .pagination-container {
  555. display: flex;
  556. justify-content: center;
  557. margin-top: 20px;
  558. padding: 20px 0;
  559. }
  560. /* 图片上传样式 */
  561. .image-uploader {
  562. :deep(.el-upload) {
  563. border: 1px dashed #d9d9d9;
  564. border-radius: 6px;
  565. cursor: pointer;
  566. position: relative;
  567. overflow: hidden;
  568. transition: all 0.3s;
  569. width: 150px;
  570. height: 150px;
  571. display: flex;
  572. align-items: center;
  573. justify-content: center;
  574. }
  575. :deep(.el-upload:hover) {
  576. border-color: #409eff;
  577. }
  578. }
  579. .uploader-icon {
  580. font-size: 28px;
  581. color: #8c939d;
  582. }
  583. .uploaded-image {
  584. width: 150px;
  585. height: 150px;
  586. display: block;
  587. }
  588. .upload-tip {
  589. font-size: 12px;
  590. color: #999;
  591. margin-top: 8px;
  592. }
  593. /* 详情对话框样式 */
  594. .detail-content {
  595. padding: 0;
  596. }
  597. .detail-section {
  598. margin-bottom: 20px;
  599. }
  600. .detail-section:last-child {
  601. margin-bottom: 0;
  602. }
  603. .section-header {
  604. display: flex;
  605. align-items: center;
  606. gap: 8px;
  607. font-weight: 600;
  608. color: #333;
  609. }
  610. .section-header .el-icon {
  611. font-size: 18px;
  612. color: #409eff;
  613. }
  614. .description-content {
  615. white-space: pre-wrap;
  616. word-break: break-word;
  617. line-height: 1.6;
  618. color: #666;
  619. }
  620. </style>