ActivityList.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  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="6">
  8. <el-button type="primary" icon="Plus" @click="$router.push('/activity/create')">
  9. 新增活动
  10. </el-button>
  11. <el-button icon="Refresh" @click="loadActivityList">刷新</el-button>
  12. </el-col>
  13. <el-col :span="18">
  14. <el-space wrap>
  15. <el-select v-model="filters.type" placeholder="活动类型" clearable style="width: 120px" @change="loadActivityList">
  16. <el-option label="全部" :value="undefined" />
  17. <el-option label="线上" :value="1" />
  18. <el-option label="线下" :value="2" />
  19. </el-select>
  20. <el-select v-model="filters.status" placeholder="活动状态" clearable style="width: 120px" @change="loadActivityList">
  21. <el-option label="全部" :value="undefined" />
  22. <el-option label="未开始" :value="1" />
  23. <el-option label="进行中" :value="2" />
  24. <el-option label="已结束" :value="3" />
  25. </el-select>
  26. <el-input
  27. v-model="filters.keyword"
  28. placeholder="搜索活动名称"
  29. clearable
  30. style="width: 200px"
  31. @clear="loadActivityList"
  32. @keyup.enter="loadActivityList"
  33. >
  34. <template #append>
  35. <el-button icon="Search" @click="loadActivityList" />
  36. </template>
  37. </el-input>
  38. </el-space>
  39. </el-col>
  40. </el-row>
  41. </el-card>
  42. <!-- 活动列表 -->
  43. <el-card shadow="never" class="table-card">
  44. <el-table
  45. v-loading="loading"
  46. :data="activityList"
  47. stripe
  48. style="width: 100%"
  49. >
  50. <template #empty>
  51. <div class="custom-empty-state">
  52. <el-icon class="empty-icon"><Calendar /></el-icon>
  53. <div class="empty-text">暂无活动数据</div>
  54. <div class="empty-hint">点击"新增活动"按钮开始创建</div>
  55. </div>
  56. </template>
  57. <el-table-column type="index" label="序号" width="60" />
  58. <el-table-column prop="coverImage" label="封面" width="120">
  59. <template #default="{ row }">
  60. <el-image
  61. :src="getCoverImage(row)"
  62. fit="cover"
  63. class="cover-image"
  64. :preview-src-list="getCoverPreviewList(row)"
  65. >
  66. <template #error>
  67. <div class="image-slot">
  68. <el-icon><Picture /></el-icon>
  69. </div>
  70. </template>
  71. </el-image>
  72. </template>
  73. </el-table-column>
  74. <el-table-column prop="name" label="活动名称" min-width="150">
  75. <template #default="{ row }">
  76. <span class="data-highlight text-ellipsis-line" :title="row.name">
  77. {{ row.name }}
  78. </span>
  79. </template>
  80. </el-table-column>
  81. <el-table-column prop="type" label="类型" width="90" align="center">
  82. <template #default="{ row }">
  83. <el-tag :type="row.type === 1 ? 'success' : 'primary'" size="small" effect="light">
  84. {{ row.type === 1 ? '线上' : '线下' }}
  85. </el-tag>
  86. </template>
  87. </el-table-column>
  88. <el-table-column prop="category" label="类别" width="100" />
  89. <el-table-column prop="location" label="地点" min-width="120">
  90. <template #default="{ row }">
  91. <span class="data-description text-ellipsis-line" :title="row.location">
  92. {{ row.location || '-' }}
  93. </span>
  94. </template>
  95. </el-table-column>
  96. <el-table-column label="报名人数" width="120" align="center">
  97. <template #default="{ row }">
  98. <span :class="row.actualParticipants >= row.maxParticipants ? 'amount-text negative' : 'number-emphasis'">
  99. {{ row.actualParticipants || 0 }}
  100. </span>
  101. <span class="data-meta"> / {{ row.maxParticipants }}</span>
  102. </template>
  103. </el-table-column>
  104. <el-table-column prop="price" label="费用" width="100" align="right">
  105. <template #default="{ row }">
  106. <span v-if="row.price > 0" class="amount-text">¥{{ row.price }}</span>
  107. <el-tag v-else class="status-success" size="small" effect="light">免费</el-tag>
  108. </template>
  109. </el-table-column>
  110. <el-table-column prop="status" label="状态" width="100" align="center">
  111. <template #default="{ row }">
  112. <el-tag
  113. :class="{
  114. 'status-pending': row.status === 1,
  115. 'status-processing': row.status === 2,
  116. 'status-disabled': row.status === 3
  117. }"
  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. total.value = response.data.total || activityList.value.length
  271. }
  272. } catch (error) {
  273. console.error('加载活动列表失败:', error)
  274. ElMessage.error('加载活动列表失败')
  275. } finally {
  276. loading.value = false
  277. }
  278. }
  279. // 获取状态文本
  280. const getStatusText = (status) => {
  281. const texts = { 1: '未开始', 2: '进行中', 3: '已结束' }
  282. return texts[status] || '未知'
  283. }
  284. // 获取状态颜色
  285. const getStatusType = (status) => {
  286. const types = { 1: 'info', 2: 'success', 3: '' }
  287. return types[status] || ''
  288. }
  289. // 查看报名情况
  290. const handleViewRegistrations = (row) => {
  291. router.push(`/activity/registrations/${row.id}`)
  292. }
  293. // 格式化日期时间
  294. const formatDateTime = (dateTime) => {
  295. if (!dateTime) return '-'
  296. // 如果是字符串,兼容 '2025-01-01T12:00:00' 或 '2025-01-01 12:00:00'
  297. if (typeof dateTime === 'string') {
  298. const normalized = dateTime.replace('T', ' ')
  299. return normalized.substring(0, 19)
  300. }
  301. // 如果是时间戳(秒或毫秒),转换为日期字符串
  302. if (typeof dateTime === 'number') {
  303. const ts = dateTime > 1e12 ? dateTime : dateTime * 1000
  304. const d = new Date(ts)
  305. const pad = (n) => (n < 10 ? `0${n}` : n)
  306. const Y = d.getFullYear()
  307. const M = pad(d.getMonth() + 1)
  308. const D = pad(d.getDate())
  309. const h = pad(d.getHours())
  310. const m = pad(d.getMinutes())
  311. const s = pad(d.getSeconds())
  312. return `${Y}-${M}-${D} ${h}:${m}:${s}`
  313. }
  314. // 其它类型(如 Date 对象)直接格式化
  315. if (dateTime instanceof Date) {
  316. const d = dateTime
  317. const pad = (n) => (n < 10 ? `0${n}` : n)
  318. const Y = d.getFullYear()
  319. const M = pad(d.getMonth() + 1)
  320. const D = pad(d.getDate())
  321. const h = pad(d.getHours())
  322. const m = pad(d.getMinutes())
  323. const s = pad(d.getSeconds())
  324. return `${Y}-${M}-${D} ${h}:${m}:${s}`
  325. }
  326. return String(dateTime)
  327. }
  328. // 切换热门状态
  329. const handleHotChange = async (row, newValue) => {
  330. const oldValue = row.isHot
  331. // 立即更新UI
  332. row.isHot = newValue
  333. try {
  334. const response = await request.put(`${API_ENDPOINTS.ACTIVITY_UPDATE}/${row.id}`, {
  335. isHot: newValue
  336. })
  337. if (response.code === 200) {
  338. ElMessage.success('状态更新成功')
  339. // 重新加载列表以确保数据同步
  340. loadActivityList()
  341. } else {
  342. // 恢复原值
  343. row.isHot = oldValue
  344. ElMessage.error(response.message || '状态更新失败')
  345. }
  346. } catch (error) {
  347. console.error('状态更新失败:', error)
  348. // 恢复原值
  349. row.isHot = oldValue
  350. ElMessage.error('状态更新失败,请重试')
  351. }
  352. }
  353. // 删除活动
  354. const handleDelete = async (row) => {
  355. try {
  356. await ElMessageBox.confirm('确定要删除这个活动吗?删除后将无法恢复!', '提示', {
  357. confirmButtonText: '确定',
  358. cancelButtonText: '取消',
  359. type: 'warning'
  360. })
  361. const response = await request.delete(`${API_ENDPOINTS.ACTIVITY_DELETE}/${row.id}`)
  362. if (response.code === 200) {
  363. ElMessage.success('删除成功')
  364. loadActivityList()
  365. }
  366. } catch (error) {
  367. if (error !== 'cancel') {
  368. console.error('删除失败:', error)
  369. }
  370. }
  371. }
  372. onMounted(() => {
  373. loadActivityList()
  374. })
  375. </script>
  376. <style scoped>
  377. @import '@/assets/list-common.css';
  378. .activity-list-container {
  379. padding: 0;
  380. }
  381. /* 活动封面图片 */
  382. .cover-image {
  383. width: 90px;
  384. height: 60px;
  385. border-radius: var(--radius-base);
  386. cursor: pointer;
  387. border: 2px solid var(--border-color);
  388. transition: all var(--transition-base);
  389. box-shadow: var(--shadow-sm);
  390. }
  391. .cover-image:hover {
  392. border-color: var(--primary-color);
  393. transform: scale(1.05);
  394. box-shadow: var(--shadow-md);
  395. }
  396. .image-slot {
  397. display: flex;
  398. align-items: center;
  399. justify-content: center;
  400. width: 90px;
  401. height: 60px;
  402. background-color: var(--bg-tertiary);
  403. color: var(--text-tertiary);
  404. font-size: 20px;
  405. border-radius: var(--radius-base);
  406. border: 1px solid var(--border-color);
  407. }
  408. </style>