ActivityList.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. <template>
  2. <div class="activity-list-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="8">
  8. <el-space>
  9. <el-button type="primary" icon="Plus" @click="$router.push('/activity/create')">
  10. 新增活动
  11. </el-button>
  12. <el-button icon="Refresh" @click="loadActivityList">刷新</el-button>
  13. </el-space>
  14. </el-col>
  15. <el-col :span="16">
  16. <el-space style="justify-content: flex-end; width: 100%;">
  17. <el-select v-model="filters.type" placeholder="活动类型" clearable style="width: 120px" @change="loadActivityList">
  18. <el-option label="全部" :value="undefined" />
  19. <el-option label="线上" :value="1" />
  20. <el-option label="线下" :value="2" />
  21. </el-select>
  22. <el-select v-model="filters.status" placeholder="活动状态" clearable style="width: 120px" @change="loadActivityList">
  23. <el-option label="全部" :value="undefined" />
  24. <el-option label="未开始" :value="1" />
  25. <el-option label="进行中" :value="2" />
  26. <el-option label="已结束" :value="3" />
  27. </el-select>
  28. <el-input
  29. v-model="filters.keyword"
  30. placeholder="搜索活动名称"
  31. clearable
  32. style="width: 200px"
  33. @clear="loadActivityList"
  34. @keyup.enter="loadActivityList"
  35. >
  36. <template #append>
  37. <el-button icon="Search" @click="loadActivityList" />
  38. </template>
  39. </el-input>
  40. </el-space>
  41. </el-col>
  42. </el-row>
  43. </el-card>
  44. <!-- 活动列表 -->
  45. <el-card shadow="never" class="table-card">
  46. <el-table
  47. v-loading="loading"
  48. :data="activityList"
  49. stripe
  50. style="width: 100%"
  51. >
  52. <template #empty>
  53. <div class="custom-empty-state">
  54. <el-icon class="empty-icon"><Calendar /></el-icon>
  55. <div class="empty-text">暂无活动数据</div>
  56. <div class="empty-hint">点击"新增活动"按钮开始创建</div>
  57. </div>
  58. </template>
  59. <el-table-column type="index" label="序号" width="60" />
  60. <el-table-column prop="coverImage" label="封面" width="120">
  61. <template #default="{ row }">
  62. <el-image
  63. :src="getCoverImage(row)"
  64. fit="cover"
  65. class="cover-image"
  66. :preview-src-list="getCoverPreviewList(row)"
  67. preview-teleported
  68. hide-on-click-modal
  69. >
  70. <template #error>
  71. <div class="image-slot">
  72. <el-icon><Picture /></el-icon>
  73. </div>
  74. </template>
  75. </el-image>
  76. </template>
  77. </el-table-column>
  78. <el-table-column prop="name" label="活动名称" min-width="150">
  79. <template #default="{ row }">
  80. <span class="data-highlight text-ellipsis-line" :title="row.name">
  81. {{ row.name }}
  82. </span>
  83. </template>
  84. </el-table-column>
  85. <el-table-column prop="type" label="类型" width="90" align="center">
  86. <template #default="{ row }">
  87. <el-tag :type="row.type === 1 ? 'success' : 'primary'" size="small" effect="light">
  88. {{ row.type === 1 ? '线上' : '线下' }}
  89. </el-tag>
  90. </template>
  91. </el-table-column>
  92. <el-table-column prop="category" label="类别" width="100" />
  93. <el-table-column prop="location" label="地点" min-width="120">
  94. <template #default="{ row }">
  95. <span class="data-description text-ellipsis-line" :title="row.location">
  96. {{ row.location || '-' }}
  97. </span>
  98. </template>
  99. </el-table-column>
  100. <el-table-column label="报名人数" width="120" align="center">
  101. <template #default="{ row }">
  102. <span :class="row.actualParticipants >= row.maxParticipants ? 'amount-text negative' : 'number-emphasis'">
  103. {{ row.actualParticipants || 0 }}
  104. </span>
  105. <span class="data-meta"> / {{ row.maxParticipants }}</span>
  106. </template>
  107. </el-table-column>
  108. <el-table-column prop="price" label="费用" width="100" align="right">
  109. <template #default="{ row }">
  110. <span v-if="row.price > 0" class="amount-text">¥{{ row.price }}</span>
  111. <el-tag v-else class="status-success" size="small" effect="light">免费</el-tag>
  112. </template>
  113. </el-table-column>
  114. <el-table-column prop="status" label="状态" width="100" align="center">
  115. <template #default="{ row }">
  116. <el-tag
  117. :type="row.status === 1 ? 'info' : row.status === 2 ? 'success' : 'danger'"
  118. size="small"
  119. effect="light"
  120. >
  121. {{ getStatusText(row.status) }}
  122. </el-tag>
  123. </template>
  124. </el-table-column>
  125. <el-table-column prop="isHot" label="热门" width="80" align="center">
  126. <template #default="{ row }">
  127. <el-switch
  128. :model-value="Boolean(row.isHot)"
  129. active-color="var(--danger-color)"
  130. inactive-color="var(--border-dark)"
  131. @change="(val) => handleHotChange(row, val)"
  132. />
  133. </template>
  134. </el-table-column>
  135. <el-table-column prop="startTime" label="开始时间" width="180">
  136. <template #default="{ row }">
  137. <span v-if="row.startTime">{{ formatDateTime(row.startTime) }}</span>
  138. <span v-else class="data-meta">-</span>
  139. </template>
  140. </el-table-column>
  141. <el-table-column prop="endTime" label="结束时间" width="180">
  142. <template #default="{ row }">
  143. <span v-if="row.endTime">{{ formatDateTime(row.endTime) }}</span>
  144. <span v-else class="data-meta">-</span>
  145. </template>
  146. </el-table-column>
  147. <el-table-column prop="registrationEndTime" label="报名截止时间" width="180">
  148. <template #default="{ row }">
  149. <span v-if="row.registrationEndTime">{{ formatDateTime(row.registrationEndTime) }}</span>
  150. <span v-else class="data-meta">-</span>
  151. </template>
  152. </el-table-column>
  153. <el-table-column label="操作" width="240" fixed="right">
  154. <template #default="{ row }">
  155. <el-button
  156. type="primary"
  157. size="small"
  158. link
  159. icon="View"
  160. @click="handleViewRegistrations(row)"
  161. >
  162. 报名
  163. </el-button>
  164. <el-button
  165. type="primary"
  166. size="small"
  167. link
  168. icon="Edit"
  169. @click="$router.push(`/activity/edit/${row.id}`)"
  170. >
  171. 编辑
  172. </el-button>
  173. <el-button
  174. type="danger"
  175. size="small"
  176. link
  177. icon="Delete"
  178. @click="handleDelete(row)"
  179. >
  180. 删除
  181. </el-button>
  182. </template>
  183. </el-table-column>
  184. </el-table>
  185. <!-- 分页 -->
  186. <div class="pagination-container">
  187. <el-pagination
  188. v-model:current-page="currentPage"
  189. v-model:page-size="pageSize"
  190. :total="total"
  191. :page-sizes="[10, 20, 50, 100]"
  192. layout="total, sizes, prev, pager, next, jumper"
  193. @size-change="loadActivityList"
  194. @current-change="loadActivityList"
  195. />
  196. </div>
  197. </el-card>
  198. </div>
  199. </template>
  200. <script setup>
  201. import { ref, reactive, onMounted } from 'vue'
  202. import { useRouter } from 'vue-router'
  203. import { ElMessage, ElMessageBox } from 'element-plus'
  204. import { Calendar, Picture } from '@element-plus/icons-vue'
  205. import request from '@/utils/request'
  206. import { API_BASE_URL, API_ENDPOINTS } from '@/config/api'
  207. const router = useRouter()
  208. const loading = ref(false)
  209. const currentPage = ref(1)
  210. const pageSize = ref(10)
  211. const total = ref(0)
  212. const activityList = ref([])
  213. const filters = reactive({
  214. type: null,
  215. status: null,
  216. keyword: ''
  217. })
  218. // 规范化图片地址,兼容相对路径和缺少协议的情况
  219. const resolveImageUrl = (url) => {
  220. if (!url || typeof url !== 'string') return ''
  221. const normalized = url.trim()
  222. if (/^https?:\/\//i.test(normalized)) return normalized
  223. if (normalized.startsWith('//')) return `${window.location.protocol}${normalized}`
  224. const base = API_BASE_URL || window.location.origin
  225. if (normalized.startsWith('/')) return `${base}${normalized}`
  226. return `${base}/${normalized}`
  227. }
  228. // 获取封面图,兼容不同字段命名
  229. const getCoverImage = (row) => {
  230. const url = row?.coverImage || row?.cover_image || row?.cover || ''
  231. return resolveImageUrl(url)
  232. }
  233. const getCoverPreviewList = (row) => {
  234. const url = getCoverImage(row)
  235. return url ? [url] : []
  236. }
  237. // 加载活动列表
  238. const loadActivityList = async () => {
  239. loading.value = true
  240. try {
  241. const response = await request.get(API_ENDPOINTS.ACTIVITY_LIST, {
  242. params: {
  243. page: currentPage.value,
  244. pageSize: pageSize.value,
  245. type: filters.type,
  246. status: filters.status,
  247. keyword: filters.keyword
  248. }
  249. })
  250. if (response.code === 200) {
  251. const list = response.data.list || response.data || []
  252. // 字段兼容与转换:确保 isHot 为布尔,并兼容不同时间字段命名
  253. activityList.value = list.map(item => ({
  254. ...item,
  255. isHot:
  256. item.isHot === true ||
  257. item.isHot === 1 ||
  258. item.isHot === '1' ||
  259. item.is_hot === true ||
  260. item.is_hot === 1,
  261. // 兼容下划线命名与不同字段名
  262. startTime: item.startTime || item.start_time || item.start_at || '',
  263. endTime: item.endTime || item.end_time || item.end_at || '',
  264. registrationEndTime:
  265. item.registrationEndTime ||
  266. item.registration_end_time ||
  267. item.signup_end_time ||
  268. '',
  269. // 兼容报名人数字段
  270. actualParticipants: item.actualParticipants ?? item.actual_participants ?? 0,
  271. maxParticipants: item.maxParticipants ?? item.max_participants
  272. }))
  273. total.value = response.data.total || activityList.value.length
  274. }
  275. } catch (error) {
  276. console.error('加载活动列表失败:', error)
  277. ElMessage.error('加载活动列表失败')
  278. } finally {
  279. loading.value = false
  280. }
  281. }
  282. // 获取状态文本
  283. const getStatusText = (status) => {
  284. const texts = { 1: '未开始', 2: '进行中', 3: '已结束' }
  285. return texts[status] || '未知'
  286. }
  287. // 获取状态颜色
  288. const getStatusType = (status) => {
  289. const types = { 1: 'info', 2: 'success', 3: '' }
  290. return types[status] || ''
  291. }
  292. // 查看报名情况
  293. const handleViewRegistrations = (row) => {
  294. router.push(`/activity/registrations/${row.id}`)
  295. }
  296. // 格式化日期时间
  297. const formatDateTime = (dateTime) => {
  298. if (!dateTime) return '-'
  299. // 如果是字符串,兼容 '2025-01-01T12:00:00' 或 '2025-01-01 12:00:00'
  300. if (typeof dateTime === 'string') {
  301. const normalized = dateTime.replace('T', ' ')
  302. return normalized.substring(0, 19)
  303. }
  304. // 如果是时间戳(秒或毫秒),转换为日期字符串
  305. if (typeof dateTime === 'number') {
  306. const ts = dateTime > 1e12 ? dateTime : dateTime * 1000
  307. const d = new Date(ts)
  308. const pad = (n) => (n < 10 ? `0${n}` : n)
  309. const Y = d.getFullYear()
  310. const M = pad(d.getMonth() + 1)
  311. const D = pad(d.getDate())
  312. const h = pad(d.getHours())
  313. const m = pad(d.getMinutes())
  314. const s = pad(d.getSeconds())
  315. return `${Y}-${M}-${D} ${h}:${m}:${s}`
  316. }
  317. // 其它类型(如 Date 对象)直接格式化
  318. if (dateTime instanceof Date) {
  319. const d = dateTime
  320. const pad = (n) => (n < 10 ? `0${n}` : n)
  321. const Y = d.getFullYear()
  322. const M = pad(d.getMonth() + 1)
  323. const D = pad(d.getDate())
  324. const h = pad(d.getHours())
  325. const m = pad(d.getMinutes())
  326. const s = pad(d.getSeconds())
  327. return `${Y}-${M}-${D} ${h}:${m}:${s}`
  328. }
  329. return String(dateTime)
  330. }
  331. // 切换热门状态
  332. const handleHotChange = async (row, newValue) => {
  333. const oldValue = row.isHot
  334. // 立即更新UI
  335. row.isHot = newValue
  336. try {
  337. const response = await request.put(`${API_ENDPOINTS.ACTIVITY_UPDATE}/${row.id}`, {
  338. isHot: newValue
  339. })
  340. if (response.code === 200) {
  341. ElMessage.success('状态更新成功')
  342. // 重新加载列表以确保数据同步
  343. loadActivityList()
  344. } else {
  345. // 恢复原值
  346. row.isHot = oldValue
  347. ElMessage.error(response.message || '状态更新失败')
  348. }
  349. } catch (error) {
  350. console.error('状态更新失败:', error)
  351. // 恢复原值
  352. row.isHot = oldValue
  353. ElMessage.error('状态更新失败,请重试')
  354. }
  355. }
  356. // 删除活动
  357. const handleDelete = async (row) => {
  358. try {
  359. await ElMessageBox.confirm('确定要删除这个活动吗?删除后将无法恢复!', '提示', {
  360. confirmButtonText: '确定',
  361. cancelButtonText: '取消',
  362. type: 'warning'
  363. })
  364. const response = await request.delete(`${API_ENDPOINTS.ACTIVITY_DELETE}/${row.id}`)
  365. if (response.code === 200) {
  366. ElMessage.success('删除成功')
  367. loadActivityList()
  368. }
  369. } catch (error) {
  370. if (error !== 'cancel') {
  371. console.error('删除失败:', error)
  372. }
  373. }
  374. }
  375. onMounted(() => {
  376. loadActivityList()
  377. })
  378. </script>
  379. <style scoped>
  380. .activity-list-container {
  381. padding: 20px;
  382. }
  383. .page-title {
  384. margin: 0 0 20px 0;
  385. font-size: 24px;
  386. font-weight: 600;
  387. color: #303133;
  388. }
  389. .toolbar-card {
  390. margin-bottom: 20px;
  391. }
  392. .table-card {
  393. margin-bottom: 20px;
  394. }
  395. /* 活动封面图片 */
  396. .cover-image {
  397. width: 90px;
  398. height: 60px;
  399. border-radius: var(--radius-base);
  400. cursor: pointer;
  401. border: 2px solid var(--border-color);
  402. transition: all var(--transition-base);
  403. box-shadow: var(--shadow-sm);
  404. }
  405. .cover-image:hover {
  406. border-color: var(--primary-color);
  407. transform: scale(1.05);
  408. box-shadow: var(--shadow-md);
  409. }
  410. .image-slot {
  411. display: flex;
  412. align-items: center;
  413. justify-content: center;
  414. width: 90px;
  415. height: 60px;
  416. background-color: var(--bg-tertiary);
  417. color: var(--text-tertiary);
  418. font-size: 20px;
  419. border-radius: var(--radius-base);
  420. border: 1px solid var(--border-color);
  421. }
  422. </style>