HomeBannerManage.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. <template>
  2. <div class="home-banner-manage admin-page">
  3. <el-card>
  4. <div slot="header" class="admin-page-header">
  5. <span class="admin-page-title">首页广告栏</span>
  6. <el-button type="primary" size="mini" icon="el-icon-plus" @click="openDialog()">新增广告位</el-button>
  7. </div>
  8. <div class="filter-bar" style="margin-bottom:12px;">
  9. <el-select v-model="filterStatus" placeholder="启用状态" @change="loadList" clearable style="width:120px;" clearable>
  10. <el-option label="全部状态" value="" />
  11. <el-option label="已启用" value="1" />
  12. <el-option label="已禁用" value="0" />
  13. </el-select>
  14. <el-select v-model="filterType" placeholder="类型" @change="loadList" clearable style="width:120px;">
  15. <el-option label="全部类型" value="" />
  16. <el-option label="活动" value="activity" />
  17. <el-option label="商品" value="product" />
  18. <el-option label="链接" value="link" />
  19. </el-select>
  20. <el-button type="primary" size="mini" @click="loadList">查询</el-button>
  21. <el-button size="mini" @click="resetFilter">重置</el-button>
  22. </div>
  23. <div class="table-scroll-wrap-sm">
  24. <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
  25. <el-table-column prop="id" label="ID" width="60" />
  26. <el-table-column label="类型" width="80">
  27. <template slot-scope="{ row }">
  28. <el-tag size="mini" :type="typeTag(row.bannerType)">{{ typeLabel(row.bannerType) }}</el-tag>
  29. </template>
  30. </el-table-column>
  31. <el-table-column label="标题" min-width="120" show-overflow-tooltip>
  32. <template slot="header"><span class="sort-header" @click.stop="onSortClick('title')">标题<span v-if="sortIndex('title') !== null" class="sort-index" :class="sortClass('title') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('title') }}</span>{{ sortIcon('title') }}</span></template>
  33. <template slot-scope="{ row }">{{ row.title }}</template>
  34. </el-table-column>
  35. <el-table-column label="关联内容" width="160">
  36. <template slot-scope="{ row }">
  37. <div v-if="row.bannerType === 'activity' && row.refId">
  38. <el-tag size="mini" type="primary">活动 #{{ row.refId }}</el-tag>
  39. </div>
  40. <div v-else-if="row.bannerType === 'product' && row.refId">
  41. <el-tag size="mini" type="info">商品 #{{ row.refId }}</el-tag>
  42. </div>
  43. <div v-else-if="row.bannerType === 'link' && row.linkUrl">
  44. <el-tag size="mini" type="info">{{ row.linkUrl }}</el-tag>
  45. </div>
  46. <div v-else style="color:#bbb">-</div>
  47. </template>
  48. </el-table-column>
  49. <el-table-column label="封面" width="80">
  50. <template slot-scope="{ row }">
  51. <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:35px;object-fit:cover;border-radius:4px;" />
  52. <span v-else style="color:#999">无</span>
  53. </template>
  54. </el-table-column>
  55. <el-table-column label="排序" width="80">
  56. <template slot-scope="{ row }">
  57. <el-input-number v-model="row.sortOrder" :min="0" :max="999" size="mini" @change="onSortChange(row)" controls-position="right" />
  58. </template>
  59. </el-table-column>
  60. <el-table-column label="投放时间" width="180">
  61. <template slot-scope="{ row }">
  62. <span v-if="row.startTime || row.endTime">{{ formatDate(row.startTime) }} ~ {{ formatDate(row.endTime) }}</span>
  63. <span v-else style="color:#bbb">不限</span>
  64. </template>
  65. </el-table-column>
  66. <el-table-column label="状态" width="80">
  67. <template slot-scope="{ row }">
  68. <el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="onToggle(row)" />
  69. </template>
  70. </el-table-column>
  71. <el-table-column prop="createdAt" label="创建时间" width="160">
  72. <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
  73. </el-table-column>
  74. <el-table-column label="操作" width="140" fixed="right">
  75. <template slot-scope="{ row }">
  76. <el-button size="mini" type="primary" @click="openDialog(row)">编辑</el-button>
  77. <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
  78. </template>
  79. </el-table-column>
  80. </el-table>
  81. </div>
  82. <div class="pagination-wrap" style="margin-top:16px;text-align:right;">
  83. <el-pagination
  84. @current-change="handlePageChange"
  85. :current-page="page"
  86. :page-size="pageSize"
  87. :total="total"
  88. layout="total, prev, pager, next"
  89. background
  90. />
  91. </div>
  92. </el-card>
  93. <!-- 编辑弹窗 -->
  94. <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="800px" :close-on-click-modal="false">
  95. <el-form :model="form" :rules="rules" ref="bannerForm" label-width="150px">
  96. <el-form-item label="标题" prop="title">
  97. <el-input v-model="form.title" placeholder="展示标题,最多50字" maxlength="50" show-word-limit />
  98. </el-form-item>
  99. <el-form-item label="类型" prop="bannerType">
  100. <el-select v-model="form.bannerType" placeholder="选择类型" style="width:100%;">
  101. <el-option label="活动" value="activity" />
  102. <el-option label="商品" value="product" />
  103. <el-option label="链接" value="link" />
  104. </el-select>
  105. </el-form-item>
  106. <!-- 活动选择器 -->
  107. <el-form-item v-if="form.bannerType === 'activity'" label="关联活动">
  108. <el-select
  109. v-model="form.refId"
  110. placeholder="搜索活动"
  111. filterable
  112. :remote="true"
  113. :remote-method="searchActivities"
  114. style="width:100%"
  115. clearable
  116. >
  117. <el-option key="" label="-- 请选择 --" value="">
  118. </el-option>
  119. </el-select>
  120. </el-form-item>
  121. <!-- 商品选择器 -->
  122. <el-form-item v-if="form.bannerType === 'product'" label="关联商品">
  123. <el-select
  124. v-model="form.refId"
  125. placeholder="搜索商品"
  126. filterable
  127. :remote="true"
  128. :remote-method="searchProducts"
  129. style="width:100%"
  130. clearable
  131. >
  132. <el-option key="" label="-- 请选择 --" value="">
  133. </el-option>
  134. </el-select>
  135. </el-form-item>
  136. <!-- 链接类型表单 -->
  137. <el-form-item v-if="form.bannerType === 'link'" label="跳转链接">
  138. <el-input v-model="form.linkUrl" placeholder="小程序页面路径(如 /pages/activity/activity-detail/activity-detail?id=123)" />
  139. <el-button type="success" size="mini" @click="selectMiniPage">选择小程序页面</el-button>
  140. </el-form-item>
  141. <!-- 封面图 -->
  142. <el-form-item label="封面图">
  143. <el-upload
  144. :action="uploadActionUrl"
  145. :headers="uploadHeaders"
  146. :on-success="handleUploadSuccess"
  147. :on-error="handleUploadError"
  148. :before-upload="beforeUpload"
  149. :show-file-list="false"
  150. accept="image/jpeg,image/png,image/gif,image/webp"
  151. >
  152. <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
  153. <el-button v-else type="primary" plain>+ 上传封面</el-button>
  154. </el-upload>
  155. <div v-if="form.coverImage" style="margin-top:8px;">
  156. <el-image :src="form.coverImage" style="width:200px;height:120px;" fit="cover" />
  157. </div>
  158. <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left:10px;" @click="form.coverImage = ''">清除</el-button>
  159. <div style="margin-top:4px;font-size:12px;color:#999;">建议尺寸 750×320rpx(约 2.35:1),单张不超过 5MB</div>
  160. </el-form-item>
  161. <el-form-item label="排序">
  162. <el-input-number v-model="form.sortOrder" :min="0" :max="999" style="width:100px;" />
  163. </el-form-item>
  164. <el-form-item label="投放时间">
  165. <el-date-picker
  166. v-model="form.startTime"
  167. type="datetime"
  168. placeholder="开始时间"
  169. format="yyyy-MM-dd HH:mm:ss"
  170. value-format="timestamp"
  171. style="margin-right:8px;"
  172. />
  173. <span>至</span>
  174. <el-date-picker
  175. v-model="form.endTime"
  176. type="datetime"
  177. placeholder="结束时间"
  178. format="yyyy-MM-dd HH:mm:ss"
  179. value-format="timestamp"
  180. style="margin-left:8px;"
  181. />
  182. </el-form-item>
  183. <el-form-item label="启用">
  184. <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
  185. </el-form-item>
  186. </el-form>
  187. <div slot="footer">
  188. <el-button @click="dialogVisible = false">取消</el-button>
  189. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
  190. </div>
  191. </el-dialog>
  192. </div>
  193. </template>
  194. <script>
  195. import { getHomeBannerList, saveHomeBanner, deleteHomeBanner, batchDeleteHomeBanner, toggleHomeBanner, updateBannerSort } from '@/api/homeBanner'
  196. import { getActivityList } from '@/api/activity'
  197. import { getProductList } from '@/api/admin'
  198. // 小程序已注册页面(供链接类型选择)
  199. const MINI_PAGES = [
  200. { path: '/pages/activity/index', label: '活动中心' },
  201. { path: '/pages/discover/index', label: '发现' },
  202. { path: '/pages/shop/index/index', label: '商城' },
  203. { path: '/pages/article-center/index', label: '知识分享' },
  204. { path: '/pages/diet/index', label: '饮食' },
  205. { path: '/pages/health/report-list', label: '健康报告' },
  206. { path: '/pages/energy/index', label: '五维能量' },
  207. { path: '/pages/membership/index', label: '会员中心' },
  208. { path: '/pages/promotion/index', label: '财富中心' },
  209. { path: '/pages/tianpan/index', label: '家庭天盘' },
  210. { path: '/pages/assessment/apply', label: '申请测评' },
  211. { path: '/pages/tasks/tasks', label: '任务列表' }
  212. ]
  213. export default {
  214. data() {
  215. return {
  216. list: [],
  217. loading: false,
  218. page: 1,
  219. pageSize: 10,
  220. total: 0,
  221. filterStatus: '',
  222. filterType: '',
  223. dialogVisible: false,
  224. submitLoading: false,
  225. editId: null,
  226. form: {
  227. id: null,
  228. title: '',
  229. bannerType: 'activity',
  230. refId: null,
  231. coverImage: '',
  232. linkUrl: '',
  233. sortOrder: 0,
  234. enabled: 1,
  235. startTime: null,
  236. endTime: null
  237. },
  238. rules: {
  239. title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
  240. bannerType: [{ required: true, message: '请选择类型', trigger: 'change' }],
  241. refId: [
  242. {
  243. required: true,
  244. message: '请选择关联内容',
  245. trigger: 'blur',
  246. validator: (rule, value, callback) => {
  247. if (this.form.bannerType === 'link') {
  248. callback()
  249. } else if (!value || value <= 0) {
  250. callback(new Error('请选择关联内容'))
  251. } else {
  252. callback()
  253. }
  254. }
  255. }
  256. ],
  257. // 封面图必填(产品类型)
  258. coverImage: [
  259. {
  260. required: true,
  261. message: '请上传封面图',
  262. trigger: 'change'
  263. }
  264. ]
  265. },
  266. // 活动搜索状态
  267. activitySearchKeyword: '',
  268. activityLoading: false,
  269. activityOptions: [],
  270. activityPage: 1,
  271. // 商品搜索状态
  272. productSearchKeyword: '',
  273. productLoading: false,
  274. productOptions: [],
  275. productPage: 1,
  276. showPagePicker: false
  277. }
  278. },
  279. computed: {
  280. tableHeight() {
  281. return window.innerHeight - 280
  282. },
  283. dialogTitle() {
  284. return this.editId ? '编辑广告位' : '新增广告位'
  285. },
  286. uploadActionUrl() {
  287. const baseURL = process.env.VUE_APP_BASE_API || ''
  288. return baseURL + '/api/admin/articles/upload/image'
  289. },
  290. uploadHeaders() {
  291. const token = localStorage.getItem('token')
  292. return token ? { Authorization: 'Bearer ' + token } : {}
  293. }
  294. },
  295. mounted() {
  296. this.loadList()
  297. },
  298. methods: {
  299. loadList() {
  300. var self = this
  301. self.loading = true
  302. var params = {
  303. current: self.page,
  304. size: self.pageSize
  305. }
  306. if (self.filterStatus !== '') params.enabled = self.filterStatus
  307. if (self.filterType !== '') params.bannerType = self.filterType
  308. getHomeBannerList(params).then(function(res) {
  309. self.loading = false
  310. if (res.data && res.data.code === 200) {
  311. var d = res.data.data || {}
  312. self.list = d.list || []
  313. self.total = d.total || 0
  314. } else if (res.data && res.data.list !== undefined) {
  315. self.list = res.data.list || []
  316. self.total = res.data.total || 0
  317. }
  318. }).catch(function() {
  319. self.loading = false
  320. self.$message.error('加载失败')
  321. })
  322. },
  323. handlePageChange(newPage) {
  324. this.page = newPage
  325. this.loadList()
  326. },
  327. resetFilter() {
  328. this.filterStatus = ''
  329. this.filterType = ''
  330. this.page = 1
  331. this.loadList()
  332. },
  333. openDialog(row) {
  334. if (row) {
  335. this.editId = row.id
  336. this.form = {
  337. id: row.id,
  338. title: row.title,
  339. bannerType: row.bannerType,
  340. refId: row.refId,
  341. coverImage: row.coverImage || '',
  342. linkUrl: row.linkUrl || '',
  343. sortOrder: row.sortOrder || 0,
  344. enabled: row.enabled != null ? row.enabled : 1,
  345. startTime: row.startTime || null,
  346. endTime: row.endTime || null
  347. }
  348. // 根据类型预填搜索结果
  349. if (row.bannerType === 'activity') {
  350. this.activitySearchKeyword = ''
  351. this.activityOptions = []
  352. this.loadActivityOptions()
  353. } else if (row.bannerType === 'product') {
  354. this.productSearchKeyword = ''
  355. this.productOptions = []
  356. this.loadProductOptions()
  357. }
  358. } else {
  359. this.editId = null
  360. this.form = {
  361. id: null,
  362. title: '',
  363. bannerType: 'activity',
  364. refId: null,
  365. coverImage: '',
  366. linkUrl: '',
  367. sortOrder: 0,
  368. enabled: 1,
  369. startTime: null,
  370. endTime: null
  371. }
  372. this.activityOptions = []
  373. this.productOptions = []
  374. }
  375. this.dialogVisible = true
  376. this.$nextTick(function() {
  377. if (this.$refs.bannerForm) this.$refs.bannerForm.clearValidate()
  378. }.bind(this))
  379. },
  380. handleSubmit() {
  381. var self = this
  382. self.$refs.bannerForm.validate(function(valid) {
  383. if (!valid) return
  384. self.submitLoading = true
  385. var saveData = Object.assign({}, self.form)
  386. // 如果类型是link,清空refId;反之清空linkUrl
  387. if (saveData.bannerType === 'link') {
  388. saveData.refId = null
  389. } else {
  390. saveData.linkUrl = ''
  391. }
  392. saveHomeBanner(saveData).then(function(res) {
  393. self.submitLoading = false
  394. if (res.data && res.data.code === 200) {
  395. self.$message.success('保存成功')
  396. self.dialogVisible = false
  397. self.loadList()
  398. } else {
  399. self.$message.error(res.data && res.data.message ? res.data.message : '保存失败')
  400. }
  401. }).catch(function(e) {
  402. self.submitLoading = false
  403. self.$message.error('保存失败')
  404. })
  405. })
  406. },
  407. handleDelete(row) {
  408. var self = this
  409. this.$confirm('确定删除该广告位?', '提示', { type: 'warning' }).then(function() {
  410. deleteHomeBanner({ id: row.id }).then(function(res) {
  411. if (res.data && res.data.code === 200) {
  412. self.$message.success('删除成功')
  413. self.loadList()
  414. } else {
  415. self.$message.error('删除失败')
  416. }
  417. }).catch(function() {
  418. self.$message.error('删除失败')
  419. })
  420. }).catch(function() {})
  421. },
  422. onToggle(row) {
  423. toggleHomeBanner({ id: row.id, enabled: row.enabled }).then(function(res) {
  424. if (res.data && res.data.code === 200) {
  425. self.$message.success(row.enabled === 1 ? '已启用' : '已禁用')
  426. }
  427. }).catch(function() {})
  428. },
  429. onSortChange(row) {
  430. updateBannerSort({ id: row.id, sortOrder: row.sortOrder }).catch(function() {})
  431. },
  432. // --- 活动搜索 ---
  433. searchActivities: async function(query) {
  434. this.activityLoading = true
  435. this.activityOptions = []
  436. try {
  437. const res = await getActivityList({
  438. keyword: query || undefined,
  439. page: 1,
  440. size: 20
  441. })
  442. const data = (res && res.data) || {}
  443. this.activityOptions = data.records || []
  444. this.activityPage = 1
  445. this.activityLoading = false
  446. } catch (e) {
  447. console.error('[HomeBanner] 搜索活动失败', e)
  448. this.activityLoading = false
  449. }
  450. },
  451. loadActivityOptions() {
  452. this.searchActivities(this.activitySearchKeyword)
  453. },
  454. // 商品搜索
  455. searchProducts: async function(query) {
  456. this.productLoading = true
  457. this.productOptions = []
  458. try {
  459. const res = await getProductList({
  460. keyword: query || undefined,
  461. page: 1,
  462. size: 20
  463. })
  464. const data = (res && res.data) || {}
  465. this.productOptions = data.records || []
  466. this.productPage = 1
  467. this.productLoading = false
  468. } catch (e) {
  469. console.error('[HomeBanner] 搜索商品失败', e)
  470. this.productLoading = false
  471. }
  472. },
  473. loadProductOptions() {
  474. this.searchProducts(this.productSearchKeyword)
  475. },
  476. handleUploadSuccess(res) {
  477. if (res && res.code === 200 && res.data && res.data.url) {
  478. this.form.coverImage = res.data.url
  479. this..success('上传成功')
  480. } else {
  481. this..error('上传失败')
  482. }
  483. },
  484. handleUploadError(err) {
  485. console.error('[HomeBanner] 上传失败', err)
  486. this..error('图片上传失败,请重试')
  487. },
  488. beforeUpload(file) {
  489. const isImage = file.type.startsWith('image/jpeg') || file.type.startsWith('image/png') || file.type.startsWith('image/gif') || file.type.startsWith('image/webp')
  490. if (!isImage) {
  491. this..error('只能上传图片格式(jpg/png/gif/webp)')
  492. return false
  493. }
  494. const isLt5M = file.size / 1024 / 1024 < 5
  495. if (!isLt5M) {
  496. this..error('图片大小不能超过 5MB')
  497. return false
  498. }
  499. return true
  500. },
  501. selectMiniPage() {
  502. this.showPagePicker = true
  503. },
  504. selectPage(path) {
  505. this.form.linkUrl = path
  506. this.showPagePicker = false
  507. },
  508. // --- 工具方法 ---
  509. typeLabel(type) {
  510. var map = { activity: '活动', product: '商品', link: '链接' }
  511. return map[type] || type || '-'
  512. },
  513. typeTag(type) {
  514. var map = { activity: 'success', product: 'warning', link: 'info' }
  515. return map[type] || ''
  516. },
  517. sortIndex(col) {
  518. return this.sortCol === col ? (this.sortDir === 'asc' ? 1 : 2) : null
  519. },
  520. sortClass(col) {
  521. return this.sortCol === col ? this.sortDir : ''
  522. },
  523. sortIcon(col) {
  524. if (this.sortCol !== col) return '↕'
  525. return this.sortDir === 'asc' ? '↑' : '↓'
  526. },
  527. onSortClick(col) {
  528. if (this.sortCol === col) {
  529. this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'
  530. } else {
  531. this.sortCol = col
  532. this.sortDir = 'asc'
  533. }
  534. this.loadList()
  535. },
  536. formatDate(ts) {
  537. if (!ts) return '-'
  538. var d = new Date(ts)
  539. if (isNaN(d.getTime())) return '-'
  540. var y = d.getFullYear()
  541. var m = String(d.getMonth() + 1).padStart(2, '0')
  542. var day = String(d.getDate()).padStart(2, '0')
  543. var h = String(d.getHours()).padStart(2, '0')
  544. var min = String(d.getMinutes()).padStart(2, '0')
  545. var s = String(d.getSeconds()).padStart(2, '0')
  546. return y + '-' + m + '-' + day + ' ' + h + ':' + min + ':' + s
  547. },
  548. formatTime(t) {
  549. if (!t) return '-'
  550. var ts = t
  551. if (typeof t === 'string' && t.indexOf('T') > -1) {
  552. ts = new Date(t).getTime()
  553. }
  554. return this.formatDate(ts)
  555. },
  556. // 选择小程序页面
  557. selectMiniPage() {
  558. this.$message.info('小程序页面选择功能待后端开发')
  559. },
  560. beforeUpload(file) {
  561. const isJPG = file.type === 'image/jpeg'
  562. const isPNG = file.type === 'image/png'
  563. const isGIF = file.type === 'image/gif'
  564. const isQQ = file.type === 'image/webp'
  565. if (!isJPG && !isPNG && !isGIF && !isQQ) {
  566. this.$message.error('只能上传 jpg/png/gif/webp 格式的图片!')
  567. return false
  568. }
  569. this.$message.warning('上传图片受限,请使用后台上传接口上传封面图')
  570. return false
  571. }
  572. }
  573. }
  574. </script>
  575. <style scoped>
  576. .admin-page-header {
  577. display: flex;
  578. justify-content: space-between;
  579. align-items: center;
  580. }
  581. .admin-page-title {
  582. font-size: 18px;
  583. font-weight: 600;
  584. color: #303133;
  585. }
  586. .filter-bar {
  587. display: flex;
  588. align-items: center;
  589. gap: 12px;
  590. flex-wrap: wrap;
  591. }
  592. </style>