VipPackageList.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. <template>
  2. <div class="vip-package-container">
  3. <h2 class="page-title">VIP套餐管理</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.status" placeholder="状态" clearable style="width: 120px" @change="loadList">
  14. <el-option label="全部" :value="undefined" />
  15. <el-option label="启用" :value="1" />
  16. <el-option label="禁用" :value="0" />
  17. </el-select>
  18. <el-input v-model="filters.keyword" placeholder="搜索套餐名称" clearable style="width: 200px" @keyup.enter="loadList">
  19. <template #append>
  20. <el-button icon="Search" @click="loadList" />
  21. </template>
  22. </el-input>
  23. </el-space>
  24. </el-col>
  25. </el-row>
  26. </el-card>
  27. <!-- 卡片列表 -->
  28. <div v-loading="loading" class="package-cards-container">
  29. <el-row :gutter="20">
  30. <el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="pkg in list" :key="pkg.packageId">
  31. <el-card shadow="hover" class="package-card" :class="{ 'recommend-card': pkg.isRecommend === 1 }">
  32. <!-- 推荐标签 -->
  33. <div v-if="pkg.isRecommend === 1" class="recommend-badge">
  34. <span class="badge-text">🔥 推荐</span>
  35. </div>
  36. <!-- 套餐名称 -->
  37. <div class="package-header">
  38. <h3 class="package-name">{{ pkg.packageName }}</h3>
  39. <el-tag :type="pkg.status === 1 ? 'success' : 'info'" size="small">
  40. {{ pkg.status === 1 ? '启用' : '禁用' }}
  41. </el-tag>
  42. </div>
  43. <!-- 价格 -->
  44. <div class="package-price">
  45. <div class="original-price">原价:¥{{ pkg.originalPrice }}</div>
  46. <div class="current-price">¥{{ pkg.currentPrice }}</div>
  47. <div class="price-unit">/ {{ pkg.durationDays }}天</div>
  48. </div>
  49. <!-- 权益说明 -->
  50. <div class="package-benefits">
  51. <div class="benefits-title">套餐权益:</div>
  52. <div class="benefits-content">{{ pkg.benefits || '暂无说明' }}</div>
  53. </div>
  54. <!-- 底部操作 -->
  55. <div class="package-footer">
  56. <el-space>
  57. <el-button type="primary" size="small" @click="showEditDialog(pkg)">
  58. <el-icon><Edit /></el-icon>
  59. 编辑
  60. </el-button>
  61. <el-button type="danger" size="small" @click="handleDelete(pkg)">
  62. <el-icon><Delete /></el-icon>
  63. 删除
  64. </el-button>
  65. <el-switch
  66. :model-value="pkg.status"
  67. :active-value="1"
  68. :inactive-value="0"
  69. inline-prompt
  70. active-text="启"
  71. inactive-text="禁"
  72. @change="handleStatusChange(pkg)"
  73. />
  74. </el-space>
  75. </div>
  76. <!-- 角标信息 -->
  77. <div class="package-badge-info">
  78. <span class="sort-badge">排序: {{ pkg.sortOrder }}</span>
  79. </div>
  80. </el-card>
  81. </el-col>
  82. </el-row>
  83. <!-- 空状态 -->
  84. <el-empty v-if="!loading && list.length === 0" description="暂无VIP套餐" />
  85. <!-- 分页 -->
  86. <div class="pagination-container" v-if="list.length > 0">
  87. <el-pagination
  88. v-model:current-page="currentPage"
  89. v-model:page-size="pageSize"
  90. :total="total"
  91. :page-sizes="[8, 12, 20, 40]"
  92. layout="total, sizes, prev, pager, next, jumper"
  93. @size-change="loadList"
  94. @current-change="loadList"
  95. />
  96. </div>
  97. </div>
  98. <!-- 创建/编辑对话框 -->
  99. <el-dialog
  100. v-model="dialogVisible"
  101. :title="isEdit ? '编辑VIP套餐' : '添加VIP套餐'"
  102. width="600px"
  103. @close="resetForm"
  104. >
  105. <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
  106. <el-form-item label="套餐名称" prop="packageName">
  107. <el-input v-model="form.packageName" placeholder="例如:月度会员" />
  108. </el-form-item>
  109. <el-row :gutter="20">
  110. <el-col :span="12">
  111. <el-form-item label="时长(天)" prop="durationDays">
  112. <el-input-number v-model="form.durationDays" :min="1" :max="3650" style="width: 100%" />
  113. </el-form-item>
  114. </el-col>
  115. <el-col :span="12">
  116. <el-form-item label="排序" prop="sortOrder">
  117. <el-input-number v-model="form.sortOrder" :min="0" style="width: 100%" />
  118. </el-form-item>
  119. </el-col>
  120. </el-row>
  121. <el-row :gutter="20">
  122. <el-col :span="12">
  123. <el-form-item label="原价(元)" prop="originalPrice">
  124. <el-input-number v-model="form.originalPrice" :min="0" :precision="2" :step="0.01" style="width: 100%" />
  125. </el-form-item>
  126. </el-col>
  127. <el-col :span="12">
  128. <el-form-item label="现价(元)" prop="currentPrice">
  129. <el-input-number v-model="form.currentPrice" :min="0" :precision="2" :step="0.01" style="width: 100%" />
  130. </el-form-item>
  131. </el-col>
  132. </el-row>
  133. <el-form-item label="权益说明" prop="benefits">
  134. <el-input v-model="form.benefits" type="textarea" :rows="4" placeholder="请输入VIP权益说明,多个权益用换行分隔" />
  135. </el-form-item>
  136. <el-row :gutter="20">
  137. <el-col :span="12">
  138. <el-form-item label="推荐套餐" prop="isRecommend">
  139. <el-switch v-model="form.isRecommend" :active-value="1" :inactive-value="0" />
  140. </el-form-item>
  141. </el-col>
  142. <el-col :span="12">
  143. <el-form-item label="状态" prop="status">
  144. <el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
  145. </el-form-item>
  146. </el-col>
  147. </el-row>
  148. </el-form>
  149. <template #footer>
  150. <el-button @click="dialogVisible = false">取消</el-button>
  151. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
  152. </template>
  153. </el-dialog>
  154. </div>
  155. </template>
  156. <script setup>
  157. import { ref, reactive, onMounted } from 'vue'
  158. import { ElMessage, ElMessageBox } from 'element-plus'
  159. import { Plus, Search, Refresh, Edit, Delete } from '@element-plus/icons-vue'
  160. import request from '@/utils/request'
  161. const loading = ref(false)
  162. const dialogVisible = ref(false)
  163. const isEdit = ref(false)
  164. const submitLoading = ref(false)
  165. const formRef = ref(null)
  166. const currentPage = ref(1)
  167. const pageSize = ref(10)
  168. const total = ref(0)
  169. const list = ref([])
  170. const filters = reactive({
  171. status: null,
  172. keyword: ''
  173. })
  174. const form = reactive({
  175. packageId: null,
  176. packageName: '',
  177. durationDays: 30,
  178. originalPrice: 0,
  179. currentPrice: 0,
  180. isRecommend: 0,
  181. benefits: '',
  182. sortOrder: 0,
  183. status: 1
  184. })
  185. const rules = {
  186. packageName: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }],
  187. durationDays: [{ required: true, message: '请输入时长', trigger: 'blur' }],
  188. originalPrice: [{ required: true, message: '请输入原价', trigger: 'blur' }],
  189. currentPrice: [{ required: true, message: '请输入现价', trigger: 'blur' }]
  190. }
  191. const loadList = async () => {
  192. loading.value = true
  193. try {
  194. const params = {
  195. page: currentPage.value,
  196. size: pageSize.value
  197. }
  198. if (filters.status !== null && filters.status !== undefined) params.status = filters.status
  199. if (filters.keyword) params.keyword = filters.keyword
  200. const response = await request.get('/admin/vip/package/list', { params })
  201. if (response.code === 200) {
  202. // 后端返回格式: Result<Page<VipPackage>>,使用records字段
  203. list.value = response.data.records || []
  204. total.value = response.data.total || 0
  205. }
  206. } catch (error) {
  207. console.error('加载失败:', error)
  208. ElMessage.error('加载失败')
  209. } finally {
  210. loading.value = false
  211. }
  212. }
  213. const showCreateDialog = () => {
  214. isEdit.value = false
  215. resetForm()
  216. dialogVisible.value = true
  217. }
  218. const showEditDialog = (row) => {
  219. isEdit.value = true
  220. Object.assign(form, row)
  221. dialogVisible.value = true
  222. }
  223. const resetForm = () => {
  224. if (formRef.value) {
  225. formRef.value.resetFields()
  226. }
  227. form.packageId = null
  228. form.packageName = ''
  229. form.durationDays = 30
  230. form.originalPrice = 0
  231. form.currentPrice = 0
  232. form.isRecommend = 0
  233. form.benefits = ''
  234. form.sortOrder = 0
  235. form.status = 1
  236. }
  237. const handleSubmit = async () => {
  238. if (!formRef.value) return
  239. try {
  240. await formRef.value.validate()
  241. submitLoading.value = true
  242. const url = isEdit.value ? '/admin/vip/package/update' : '/admin/vip/package/create'
  243. const method = isEdit.value ? 'put' : 'post'
  244. const response = await request[method](url, form)
  245. if (response.code === 200) {
  246. ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
  247. dialogVisible.value = false
  248. loadList()
  249. } else {
  250. ElMessage.error(response.msg || '操作失败')
  251. }
  252. } catch (error) {
  253. console.error('提交失败:', error)
  254. if (error !== 'cancel') {
  255. ElMessage.error('操作失败')
  256. }
  257. } finally {
  258. submitLoading.value = false
  259. }
  260. }
  261. const handleStatusChange = async (row) => {
  262. try {
  263. const newStatus = row.status === 1 ? 0 : 1
  264. const response = await request.put('/admin/vip/package/status', {
  265. id: row.packageId,
  266. status: newStatus
  267. })
  268. if (response.code === 200) {
  269. ElMessage.success('状态更新成功')
  270. loadList()
  271. } else {
  272. ElMessage.error(response.msg || '状态更新失败')
  273. }
  274. } catch (error) {
  275. console.error('状态更新失败:', error)
  276. ElMessage.error('状态更新失败')
  277. }
  278. }
  279. const handleDelete = async (row) => {
  280. try {
  281. await ElMessageBox.confirm('确定要删除这个VIP套餐吗?', '提示', {
  282. confirmButtonText: '确定',
  283. cancelButtonText: '取消',
  284. type: 'warning'
  285. })
  286. const response = await request.delete(`/admin/vip/package/delete/${row.packageId}`)
  287. if (response.code === 200) {
  288. ElMessage.success('删除成功')
  289. loadList()
  290. } else {
  291. ElMessage.error(response.msg || '删除失败')
  292. }
  293. } catch (error) {
  294. if (error !== 'cancel') {
  295. console.error('删除失败:', error)
  296. }
  297. }
  298. }
  299. onMounted(() => loadList())
  300. </script>
  301. <style scoped>
  302. .vip-package-container {
  303. padding: 0;
  304. }
  305. .page-title {
  306. font-size: 24px;
  307. font-weight: bold;
  308. color: #333;
  309. margin: 0 0 20px 0;
  310. }
  311. .toolbar-card {
  312. margin-bottom: 20px;
  313. }
  314. .package-cards-container {
  315. min-height: 400px;
  316. padding: 20px 0;
  317. }
  318. /* 套餐卡片 */
  319. .package-card {
  320. position: relative;
  321. border-radius: 16px;
  322. margin-bottom: 20px;
  323. transition: all 0.3s ease;
  324. overflow: visible;
  325. }
  326. .package-card:hover {
  327. transform: translateY(-8px);
  328. box-shadow: 0 12px 24px rgba(233, 30, 99, 0.2) !important;
  329. }
  330. .recommend-card {
  331. border: 2px solid #E91E63;
  332. background: linear-gradient(135deg, #FFF5F7 0%, #FFFFFF 100%);
  333. }
  334. /* 推荐标签 */
  335. .recommend-badge {
  336. position: absolute;
  337. top: -10px;
  338. right: 20px;
  339. background: linear-gradient(135deg, #FF6B9D 0%, #E91E63 100%);
  340. color: white;
  341. padding: 6px 16px;
  342. border-radius: 20px;
  343. font-size: 12px;
  344. font-weight: bold;
  345. box-shadow: 0 4px 12px rgba(233, 30, 99, 0.4);
  346. z-index: 10;
  347. }
  348. .badge-text {
  349. display: flex;
  350. align-items: center;
  351. gap: 4px;
  352. }
  353. /* 套餐头部 */
  354. .package-header {
  355. display: flex;
  356. justify-content: space-between;
  357. align-items: center;
  358. margin-bottom: 20px;
  359. padding-bottom: 16px;
  360. border-bottom: 2px solid #f0f0f0;
  361. }
  362. .package-name {
  363. font-size: 20px;
  364. font-weight: bold;
  365. color: #333;
  366. margin: 0;
  367. }
  368. /* 价格区域 */
  369. .package-price {
  370. text-align: center;
  371. padding: 24px 0;
  372. background: linear-gradient(135deg, #FFF9F9 0%, #FFFFFF 100%);
  373. border-radius: 12px;
  374. margin-bottom: 20px;
  375. }
  376. .original-price {
  377. font-size: 14px;
  378. color: #999;
  379. text-decoration: line-through;
  380. margin-bottom: 8px;
  381. }
  382. .current-price {
  383. font-size: 40px;
  384. font-weight: bold;
  385. color: #E91E63;
  386. line-height: 1;
  387. margin-bottom: 4px;
  388. }
  389. .price-unit {
  390. font-size: 14px;
  391. color: #666;
  392. }
  393. /* 权益说明 */
  394. .package-benefits {
  395. padding: 16px;
  396. background: #F5F5F5;
  397. border-radius: 8px;
  398. margin-bottom: 16px;
  399. min-height: 80px;
  400. }
  401. .benefits-title {
  402. font-size: 12px;
  403. color: #999;
  404. margin-bottom: 8px;
  405. }
  406. .benefits-content {
  407. font-size: 14px;
  408. color: #666;
  409. line-height: 1.6;
  410. white-space: pre-line;
  411. }
  412. /* 底部操作 */
  413. .package-footer {
  414. display: flex;
  415. justify-content: space-between;
  416. align-items: center;
  417. padding-top: 16px;
  418. border-top: 1px solid #f0f0f0;
  419. }
  420. /* 角标信息 */
  421. .package-badge-info {
  422. position: absolute;
  423. bottom: 16px;
  424. right: 16px;
  425. }
  426. .sort-badge {
  427. font-size: 12px;
  428. color: #999;
  429. background: #F5F5F5;
  430. padding: 4px 12px;
  431. border-radius: 12px;
  432. }
  433. /* 分页 */
  434. .pagination-container {
  435. display: flex;
  436. justify-content: center;
  437. margin-top: 40px;
  438. padding: 20px 0;
  439. }
  440. /* 响应式调整 */
  441. @media (max-width: 768px) {
  442. .package-card {
  443. margin-bottom: 16px;
  444. }
  445. .current-price {
  446. font-size: 32px;
  447. }
  448. }
  449. </style>