Post.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. <template>
  2. <div class="posts-container">
  3. <!-- 搜索表单 -->
  4. <div class="mb10">
  5. <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
  6. </div>
  7. <!-- 操作按钮 -->
  8. <div class="mb10">
  9. <div class="table-operations">
  10. <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
  11. <DeleteOutlined /> 批量删除
  12. </a-button>
  13. <span style="margin-left: 8px;" v-if="hasSelected">
  14. 已选择 {{ selectedRowKeys.length }} 项
  15. </span>
  16. </div>
  17. </div>
  18. <!-- 数据列表 -->
  19. <div class="card-wrap">
  20. <DataList ref="dataListRef" :api="(params) => postApi.getListByPage(params)" :deleteApi="postApi.deletePost"
  21. :columns="columns" :rowSelection="rowSelection" @action="onAction">
  22. <!-- 自定义列插槽 -->
  23. <template #content="{ record }">
  24. <div class="post-content-wrapper">
  25. <div class="post-text">
  26. {{ record.content ? record.content.substring(0, 100) + (record.content.length > 100 ? '...' : '') : '无内容'
  27. }}
  28. </div>
  29. <div v-if="record.media && parseMedia(record.media).length > 0" class="post-images">
  30. <a-image-preview-group>
  31. <div class="image-grid">
  32. <a-image v-for="(media, index) in parseMedia(record.media).filter(m => m && isImage(m)).slice(0, 9)"
  33. :key="`img-${record.id}-${index}`" :src="getImageUrl(media)" :width="60" :height="60"
  34. class="grid-image" :style="{ objectFit: 'cover' }" :preview="{
  35. src: getImageUrl(media)
  36. }" />
  37. </div>
  38. </a-image-preview-group>
  39. </div>
  40. </div>
  41. </template>
  42. <template #user="{ record }">
  43. <div class="user-info">
  44. <a-avatar :src="getImageUrl(record.user?.avatar)" size="small">
  45. {{ record.user?.nickname?.charAt(0) }}
  46. </a-avatar>
  47. <div class="user-details">
  48. <div>{{ record.user?.nickname || '未知用户' }}</div>
  49. </div>
  50. </div>
  51. </template>
  52. <template #topic="{ record }">
  53. <div class="topic-info">
  54. <a-avatar :src="getImageUrl(record.topic?.cover_image)" size="small" shape="square">
  55. {{ record.topic?.name?.charAt(0) }}
  56. </a-avatar>
  57. <div class="topic-details">
  58. <div class="topic-name">{{ record.topic?.name || '未知圈子' }}</div>
  59. </div>
  60. </div>
  61. </template>
  62. <template #created_at="{ record }">
  63. <div>{{ formatDate(record.created_at) }}</div>
  64. </template>
  65. <template #action="{ record }">
  66. <a-space>
  67. <a-button type="link" size="small" @click="viewDetail(record)">
  68. 查看详情
  69. </a-button>
  70. <a-popconfirm title="确定要删除这个帖子吗?" @confirm="handleDelete(record.id)">
  71. <a-button type="link" size="small" danger>
  72. 删除
  73. </a-button>
  74. </a-popconfirm>
  75. </a-space>
  76. </template>
  77. </DataList>
  78. </div>
  79. <!-- 帖子详情模态框 -->
  80. <a-modal v-model:open="detailModalVisible" title="帖子详情" width="800px" :footer="null">
  81. <div v-if="currentPost" class="post-detail">
  82. <a-descriptions :column="2" bordered>
  83. <a-descriptions-item label="标题" :span="2">
  84. {{ currentPost.title }}
  85. </a-descriptions-item>
  86. <a-descriptions-item label="内容" :span="2">
  87. <div class="post-content-detail">{{ currentPost.content }}</div>
  88. </a-descriptions-item>
  89. <a-descriptions-item label="类型">
  90. <a-tag v-if="Number(currentPost.type) === 1" color="blue">图文</a-tag>
  91. <a-tag v-else-if="Number(currentPost.type) === 2" color="orange">视频</a-tag>
  92. <a-tag v-else-if="Number(currentPost.type) === 4" color="purple">投票</a-tag>
  93. </a-descriptions-item>
  94. <a-descriptions-item label="发布者">
  95. {{ currentPost.user?.nickname || '未知用户' }}
  96. </a-descriptions-item>
  97. <a-descriptions-item label="所属圈子">
  98. {{ currentPost.topic?.name || '未知圈子' }}
  99. </a-descriptions-item>
  100. <a-descriptions-item label="地址">
  101. {{ currentPost.address || '无' }}
  102. </a-descriptions-item>
  103. <a-descriptions-item label="浏览数">
  104. {{ currentPost.see_num }}
  105. </a-descriptions-item>
  106. <a-descriptions-item label="点赞数">
  107. {{ currentPost.thumb_num }}
  108. </a-descriptions-item>
  109. <a-descriptions-item label="收藏数">
  110. {{ currentPost.collect_num }}
  111. </a-descriptions-item>
  112. <a-descriptions-item label="评论数">
  113. {{ currentPost.comment_num }}
  114. </a-descriptions-item>
  115. <a-descriptions-item label="创建时间">
  116. {{ formatDate(currentPost.created_at) }}
  117. </a-descriptions-item>
  118. <a-descriptions-item label="更新时间">
  119. {{ formatDate(currentPost.updated_at) }}
  120. </a-descriptions-item>
  121. </a-descriptions>
  122. <section v-if="currentPost.poll" class="poll-detail">
  123. <h4>投票:{{ currentPost.poll.question }}</h4>
  124. <p>{{ currentPost.poll.status_text }} · {{ currentPost.poll.participant_count }} 人参与 · {{ currentPost.poll.selection_hint }}</p>
  125. <p>结果展示:{{ ['始终可见', '投票后可见', '结束后可见'][currentPost.poll.result_visibility - 1] }};截止:{{ currentPost.poll.end_at ? formatDate(currentPost.poll.end_at * 1000) : '永久' }}</p>
  126. <div v-for="option in currentPost.poll.options" :key="option.id" class="poll-option-detail">
  127. <a-image v-if="option.image" :src="getImageUrl(option.image)" :width="64" />
  128. <div class="poll-option-result">
  129. <span>{{ option.content }} · {{ option.vote_count }} 票</span>
  130. <a-progress :percent="option.percentage" />
  131. </div>
  132. </div>
  133. <a-popconfirm v-if="currentPost.poll.status === 'active'" title="关闭后不能继续投票,确定关闭?" @confirm="closePoll">
  134. <a-button danger :loading="closingPoll">关闭投票</a-button>
  135. </a-popconfirm>
  136. </section>
  137. <!-- 媒体文件展示 -->
  138. <div v-if="currentPost.media" class="media-section" style="margin-top: 16px;">
  139. <h4>媒体文件</h4>
  140. <div class="media-list">
  141. <div v-for="(media, index) in parseMedia(currentPost.media)" :key="index" class="media-item">
  142. <img v-if="isImage(media)" :src="getImageUrl(media)" alt="图片"
  143. style="max-width: 200px; max-height: 200px;" />
  144. <video v-else-if="isVideo(media)" :src="getImageUrl(media)" controls
  145. style="max-width: 200px; max-height: 200px;"></video>
  146. <a v-else :href="getImageUrl(media)" target="_blank">{{ media }}</a>
  147. </div>
  148. </div>
  149. </div>
  150. </div>
  151. </a-modal>
  152. </div>
  153. </template>
  154. <script setup>
  155. import { notifyError } from '@/utils/request.js'
  156. import { ref, computed } from 'vue'
  157. import { message, Modal } from 'ant-design-vue'
  158. import { DeleteOutlined } from '@ant-design/icons-vue'
  159. import DataList from '@/components/DataList.vue'
  160. import SearchForm from '@/components/SearchForm.vue'
  161. import { postApi } from '@/api/content'
  162. import { formatDate } from '@/utils/date'
  163. import { getImageUrl } from '@/utils/utils'
  164. // 响应式数据
  165. const dataListRef = ref()
  166. const selectedRowKeys = ref([])
  167. const detailModalVisible = ref(false)
  168. const currentPost = ref(null)
  169. const closingPoll = ref(false)
  170. async function closePoll() {
  171. if (closingPoll.value || !currentPost.value?.poll) return
  172. closingPoll.value = true
  173. try {
  174. const response = await postApi.closePoll({ poll_id: currentPost.value.poll.id })
  175. currentPost.value.poll = response.result
  176. message.success('投票已关闭')
  177. dataListRef.value.refreshData()
  178. } catch {
  179. // 统一请求层负责展示权限与接口错误。
  180. } finally {
  181. closingPoll.value = false
  182. }
  183. }
  184. const topicOptions = ref([])
  185. // 搜索表单配置
  186. const searchItems = ref([
  187. {
  188. label: '标题',
  189. prop: 'title',
  190. type: 'input',
  191. placeholder: '请输入帖子标题'
  192. },
  193. {
  194. label: '创建时间',
  195. prop: 'date_range',
  196. type: 'daterange',
  197. placeholder: ['开始日期', '结束日期']
  198. }
  199. ])
  200. // 表格列配置
  201. const columns = [
  202. {
  203. title: '内容',
  204. dataIndex: 'content',
  205. width: 200,
  206. slot: true
  207. },
  208. {
  209. title: '发布者',
  210. dataIndex: 'user',
  211. width: 150,
  212. slot: true
  213. },
  214. {
  215. title: '圈子',
  216. dataIndex: 'topic',
  217. width: 200,
  218. slot: true
  219. },
  220. {
  221. title: '创建时间',
  222. dataIndex: 'created_at',
  223. width: 180,
  224. slot: true,
  225. sorter: true
  226. },
  227. {
  228. title: '操作',
  229. key: 'action',
  230. width: 150,
  231. type: 'action',
  232. options: [
  233. { title: '查看', key: 'view' },
  234. { title: '删除', key: 'delete', type: 'delete' }
  235. ]
  236. }
  237. ]
  238. // 行选择配置
  239. const rowSelection = {
  240. selectedRowKeys: selectedRowKeys,
  241. onChange: (keys) => {
  242. selectedRowKeys.value = keys
  243. }
  244. }
  245. // 计算属性
  246. const hasSelected = computed(() => selectedRowKeys.value.length > 0)
  247. // 方法
  248. const handleSearch = (params) => {
  249. dataListRef.value.search(params)
  250. }
  251. const handleReset = () => {
  252. dataListRef.value.reset()
  253. }
  254. const handleDelete = async (id) => {
  255. try {
  256. await postApi.deletePost({ id })
  257. message.success('删除成功')
  258. dataListRef.value.refreshData()
  259. } catch (error) {
  260. notifyError(error, '删除失败:' + error.message)
  261. }
  262. }
  263. const handleBatchDelete = () => {
  264. if (selectedRowKeys.value.length === 0) {
  265. message.warning('请选择要删除的帖子')
  266. return
  267. }
  268. Modal.confirm({
  269. title: '确认删除',
  270. content: `确定要删除选中的 ${selectedRowKeys.value.length} 个帖子吗?`,
  271. onOk: async () => {
  272. try {
  273. await postApi.batchDeletePosts({ ids: selectedRowKeys.value })
  274. message.success('批量删除成功')
  275. selectedRowKeys.value = []
  276. dataListRef.value.refreshData()
  277. } catch (error) {
  278. notifyError(error, '批量删除失败:' + error.message)
  279. }
  280. }
  281. })
  282. }
  283. const viewDetail = async (record) => {
  284. try {
  285. const response = await postApi.getPostDetail({ id: record.id })
  286. if (response && response.result) {
  287. currentPost.value = response.result
  288. detailModalVisible.value = true
  289. } else {
  290. message.error('获取帖子详情失败:响应数据为空')
  291. }
  292. } catch (error) {
  293. notifyError(error, '获取帖子详情失败:' + error.message)
  294. }
  295. }
  296. const parseMedia = (mediaStr) => {
  297. if (!mediaStr || mediaStr === 'null' || mediaStr === 'undefined') return []
  298. // 如果已经是数组,直接返回
  299. if (Array.isArray(mediaStr)) {
  300. return mediaStr.filter(Boolean)
  301. }
  302. // 尝试解析JSON
  303. try {
  304. const parsed = JSON.parse(mediaStr)
  305. if (Array.isArray(parsed)) {
  306. return parsed.filter(Boolean)
  307. }
  308. return []
  309. } catch {
  310. // 如果不是JSON,按逗号分割
  311. if (typeof mediaStr === 'string') {
  312. return mediaStr.split(',').filter(item => item && item.trim())
  313. }
  314. return []
  315. }
  316. }
  317. const isImage = (url) => {
  318. if (url && typeof url === 'object') return url.type === 'image'
  319. return /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
  320. }
  321. const isVideo = (url) => {
  322. if (url && typeof url === 'object') return url.type === 'video'
  323. return /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(url)
  324. }
  325. // 操作按钮点击事件
  326. const onAction = (action, record) => {
  327. if (action.key === 'view') {
  328. viewDetail(record)
  329. }
  330. }
  331. </script>
  332. <style scoped>
  333. .poll-detail { margin-top: 20px; padding: 16px; border: 1px solid #eee; border-radius: 8px; }
  334. .poll-option-detail { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
  335. .poll-option-result { flex: 1; min-width: 0; }
  336. .posts-container {
  337. padding: 24px;
  338. }
  339. .post-title {
  340. display: flex;
  341. flex-direction: column;
  342. gap: 4px;
  343. }
  344. .title-text {
  345. font-weight: 500;
  346. color: var(--meow-color-primary);
  347. cursor: pointer;
  348. }
  349. .post-meta {
  350. display: flex;
  351. align-items: center;
  352. gap: 8px;
  353. font-size: 12px;
  354. color: #666;
  355. }
  356. .meta-item {
  357. background: #f5f5f5;
  358. padding: 2px 6px;
  359. border-radius: 4px;
  360. }
  361. .post-content-wrapper {
  362. display: flex;
  363. flex-direction: column;
  364. gap: 8px;
  365. max-width: 200px;
  366. }
  367. .post-text {
  368. color: #666;
  369. line-height: 1.4;
  370. word-break: break-all;
  371. display: -webkit-box;
  372. -webkit-line-clamp: 2;
  373. -webkit-box-orient: vertical;
  374. overflow: hidden;
  375. text-overflow: ellipsis;
  376. }
  377. .post-images {
  378. margin-top: 4px;
  379. }
  380. .image-grid {
  381. display: grid;
  382. grid-template-columns: repeat(3, 1fr);
  383. gap: 2px;
  384. max-width: 180px;
  385. }
  386. .grid-image {
  387. object-fit: cover;
  388. border-radius: 4px;
  389. cursor: pointer;
  390. transition: transform 0.2s;
  391. display: block;
  392. }
  393. /* 确保a-image组件内的img元素不会拉伸变形 */
  394. .grid-image :deep(img) {
  395. object-fit: cover !important;
  396. width: 60px !important;
  397. height: 60px !important;
  398. }
  399. .grid-image :deep(.ant-image) {
  400. width: 60px !important;
  401. height: 60px !important;
  402. }
  403. .grid-image :deep(.ant-image-img) {
  404. object-fit: cover !important;
  405. }
  406. .grid-image:hover {
  407. transform: scale(1.05);
  408. }
  409. .user-info {
  410. display: flex;
  411. align-items: center;
  412. gap: 8px;
  413. }
  414. .user-details {
  415. display: flex;
  416. flex-direction: column;
  417. gap: 2px;
  418. }
  419. .user-id {
  420. font-size: 12px;
  421. color: #999;
  422. }
  423. .topic-info {
  424. display: flex;
  425. align-items: center;
  426. gap: 8px;
  427. }
  428. .topic-details {
  429. display: flex;
  430. flex-direction: column;
  431. gap: 2px;
  432. }
  433. .topic-name {
  434. font-weight: 500;
  435. }
  436. .topic-id {
  437. font-size: 12px;
  438. color: #999;
  439. }
  440. .post-content-detail {
  441. max-height: 200px;
  442. overflow-y: auto;
  443. line-height: 1.6;
  444. white-space: pre-wrap;
  445. }
  446. .media-section {
  447. border-top: 1px solid #f0f0f0;
  448. padding-top: 16px;
  449. }
  450. .media-list {
  451. display: flex;
  452. flex-wrap: wrap;
  453. gap: 8px;
  454. }
  455. .media-item {
  456. border: 1px solid #d9d9d9;
  457. border-radius: 4px;
  458. overflow: hidden;
  459. }
  460. .statistics-cards .ant-card {
  461. text-align: center;
  462. }
  463. .table-operations {
  464. display: flex;
  465. align-items: center;
  466. }
  467. </style>