| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- <template>
- <div class="posts-container">
- <!-- 搜索表单 -->
- <div class="mb10">
- <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
- </div>
- <!-- 操作按钮 -->
- <div class="mb10">
- <div class="table-operations">
- <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
- <DeleteOutlined /> 批量删除
- </a-button>
- <span style="margin-left: 8px;" v-if="hasSelected">
- 已选择 {{ selectedRowKeys.length }} 项
- </span>
- </div>
- </div>
- <!-- 数据列表 -->
- <div class="card-wrap">
- <DataList ref="dataListRef" :api="(params) => postApi.getListByPage(params)" :deleteApi="postApi.deletePost"
- :columns="columns" :rowSelection="rowSelection" @action="onAction">
- <!-- 自定义列插槽 -->
- <template #content="{ record }">
- <div class="post-content-wrapper">
- <div class="post-text">
- {{ record.content ? record.content.substring(0, 100) + (record.content.length > 100 ? '...' : '') : '无内容'
- }}
- </div>
- <div v-if="record.media && parseMedia(record.media).length > 0" class="post-images">
- <a-image-preview-group>
- <div class="image-grid">
- <a-image v-for="(media, index) in parseMedia(record.media).filter(m => m && isImage(m)).slice(0, 9)"
- :key="`img-${record.id}-${index}`" :src="getImageUrl(media)" :width="60" :height="60"
- class="grid-image" :style="{ objectFit: 'cover' }" :preview="{
- src: getImageUrl(media)
- }" />
- </div>
- </a-image-preview-group>
- </div>
- </div>
- </template>
- <template #user="{ record }">
- <div class="user-info">
- <a-avatar :src="getImageUrl(record.user?.avatar)" size="small">
- {{ record.user?.nickname?.charAt(0) }}
- </a-avatar>
- <div class="user-details">
- <div>{{ record.user?.nickname || '未知用户' }}</div>
- </div>
- </div>
- </template>
- <template #topic="{ record }">
- <div class="topic-info">
- <a-avatar :src="getImageUrl(record.topic?.cover_image)" size="small" shape="square">
- {{ record.topic?.name?.charAt(0) }}
- </a-avatar>
- <div class="topic-details">
- <div class="topic-name">{{ record.topic?.name || '未知圈子' }}</div>
- </div>
- </div>
- </template>
- <template #created_at="{ record }">
- <div>{{ formatDate(record.created_at) }}</div>
- </template>
- <template #action="{ record }">
- <a-space>
- <a-button type="link" size="small" @click="viewDetail(record)">
- 查看详情
- </a-button>
- <a-popconfirm title="确定要删除这个帖子吗?" @confirm="handleDelete(record.id)">
- <a-button type="link" size="small" danger>
- 删除
- </a-button>
- </a-popconfirm>
- </a-space>
- </template>
- </DataList>
- </div>
- <!-- 帖子详情模态框 -->
- <a-modal v-model:open="detailModalVisible" title="帖子详情" width="800px" :footer="null">
- <div v-if="currentPost" class="post-detail">
- <a-descriptions :column="2" bordered>
- <a-descriptions-item label="标题" :span="2">
- {{ currentPost.title }}
- </a-descriptions-item>
- <a-descriptions-item label="内容" :span="2">
- <div class="post-content-detail">{{ currentPost.content }}</div>
- </a-descriptions-item>
- <a-descriptions-item label="类型">
- <a-tag v-if="Number(currentPost.type) === 1" color="blue">图文</a-tag>
- <a-tag v-else-if="Number(currentPost.type) === 2" color="orange">视频</a-tag>
- <a-tag v-else-if="Number(currentPost.type) === 4" color="purple">投票</a-tag>
- </a-descriptions-item>
- <a-descriptions-item label="发布者">
- {{ currentPost.user?.nickname || '未知用户' }}
- </a-descriptions-item>
- <a-descriptions-item label="所属圈子">
- {{ currentPost.topic?.name || '未知圈子' }}
- </a-descriptions-item>
- <a-descriptions-item label="地址">
- {{ currentPost.address || '无' }}
- </a-descriptions-item>
- <a-descriptions-item label="浏览数">
- {{ currentPost.see_num }}
- </a-descriptions-item>
- <a-descriptions-item label="点赞数">
- {{ currentPost.thumb_num }}
- </a-descriptions-item>
- <a-descriptions-item label="收藏数">
- {{ currentPost.collect_num }}
- </a-descriptions-item>
- <a-descriptions-item label="评论数">
- {{ currentPost.comment_num }}
- </a-descriptions-item>
- <a-descriptions-item label="创建时间">
- {{ formatDate(currentPost.created_at) }}
- </a-descriptions-item>
- <a-descriptions-item label="更新时间">
- {{ formatDate(currentPost.updated_at) }}
- </a-descriptions-item>
- </a-descriptions>
- <section v-if="currentPost.poll" class="poll-detail">
- <h4>投票:{{ currentPost.poll.question }}</h4>
- <p>{{ currentPost.poll.status_text }} · {{ currentPost.poll.participant_count }} 人参与 · {{ currentPost.poll.selection_hint }}</p>
- <p>结果展示:{{ ['始终可见', '投票后可见', '结束后可见'][currentPost.poll.result_visibility - 1] }};截止:{{ currentPost.poll.end_at ? formatDate(currentPost.poll.end_at * 1000) : '永久' }}</p>
- <div v-for="option in currentPost.poll.options" :key="option.id" class="poll-option-detail">
- <a-image v-if="option.image" :src="getImageUrl(option.image)" :width="64" />
- <div class="poll-option-result">
- <span>{{ option.content }} · {{ option.vote_count }} 票</span>
- <a-progress :percent="option.percentage" />
- </div>
- </div>
- <a-popconfirm v-if="currentPost.poll.status === 'active'" title="关闭后不能继续投票,确定关闭?" @confirm="closePoll">
- <a-button danger :loading="closingPoll">关闭投票</a-button>
- </a-popconfirm>
- </section>
- <!-- 媒体文件展示 -->
- <div v-if="currentPost.media" class="media-section" style="margin-top: 16px;">
- <h4>媒体文件</h4>
- <div class="media-list">
- <div v-for="(media, index) in parseMedia(currentPost.media)" :key="index" class="media-item">
- <img v-if="isImage(media)" :src="getImageUrl(media)" alt="图片"
- style="max-width: 200px; max-height: 200px;" />
- <video v-else-if="isVideo(media)" :src="getImageUrl(media)" controls
- style="max-width: 200px; max-height: 200px;"></video>
- <a v-else :href="getImageUrl(media)" target="_blank">{{ media }}</a>
- </div>
- </div>
- </div>
- </div>
- </a-modal>
- </div>
- </template>
- <script setup>
- import { notifyError } from '@/utils/request.js'
- import { ref, computed } from 'vue'
- import { message, Modal } from 'ant-design-vue'
- import { DeleteOutlined } from '@ant-design/icons-vue'
- import DataList from '@/components/DataList.vue'
- import SearchForm from '@/components/SearchForm.vue'
- import { postApi } from '@/api/content'
- import { formatDate } from '@/utils/date'
- import { getImageUrl } from '@/utils/utils'
- // 响应式数据
- const dataListRef = ref()
- const selectedRowKeys = ref([])
- const detailModalVisible = ref(false)
- const currentPost = ref(null)
- const closingPoll = ref(false)
- async function closePoll() {
- if (closingPoll.value || !currentPost.value?.poll) return
- closingPoll.value = true
- try {
- const response = await postApi.closePoll({ poll_id: currentPost.value.poll.id })
- currentPost.value.poll = response.result
- message.success('投票已关闭')
- dataListRef.value.refreshData()
- } catch {
- // 统一请求层负责展示权限与接口错误。
- } finally {
- closingPoll.value = false
- }
- }
- const topicOptions = ref([])
- // 搜索表单配置
- const searchItems = ref([
- {
- label: '标题',
- prop: 'title',
- type: 'input',
- placeholder: '请输入帖子标题'
- },
- {
- label: '创建时间',
- prop: 'date_range',
- type: 'daterange',
- placeholder: ['开始日期', '结束日期']
- }
- ])
- // 表格列配置
- const columns = [
- {
- title: '内容',
- dataIndex: 'content',
- width: 200,
- slot: true
- },
- {
- title: '发布者',
- dataIndex: 'user',
- width: 150,
- slot: true
- },
- {
- title: '圈子',
- dataIndex: 'topic',
- width: 200,
- slot: true
- },
- {
- title: '创建时间',
- dataIndex: 'created_at',
- width: 180,
- slot: true,
- sorter: true
- },
- {
- title: '操作',
- key: 'action',
- width: 150,
- type: 'action',
- options: [
- { title: '查看', key: 'view' },
- { title: '删除', key: 'delete', type: 'delete' }
- ]
- }
- ]
- // 行选择配置
- const rowSelection = {
- selectedRowKeys: selectedRowKeys,
- onChange: (keys) => {
- selectedRowKeys.value = keys
- }
- }
- // 计算属性
- const hasSelected = computed(() => selectedRowKeys.value.length > 0)
- // 方法
- const handleSearch = (params) => {
- dataListRef.value.search(params)
- }
- const handleReset = () => {
- dataListRef.value.reset()
- }
- const handleDelete = async (id) => {
- try {
- await postApi.deletePost({ id })
- message.success('删除成功')
- dataListRef.value.refreshData()
- } catch (error) {
- notifyError(error, '删除失败:' + error.message)
- }
- }
- const handleBatchDelete = () => {
- if (selectedRowKeys.value.length === 0) {
- message.warning('请选择要删除的帖子')
- return
- }
- Modal.confirm({
- title: '确认删除',
- content: `确定要删除选中的 ${selectedRowKeys.value.length} 个帖子吗?`,
- onOk: async () => {
- try {
- await postApi.batchDeletePosts({ ids: selectedRowKeys.value })
- message.success('批量删除成功')
- selectedRowKeys.value = []
- dataListRef.value.refreshData()
- } catch (error) {
- notifyError(error, '批量删除失败:' + error.message)
- }
- }
- })
- }
- const viewDetail = async (record) => {
- try {
- const response = await postApi.getPostDetail({ id: record.id })
- if (response && response.result) {
- currentPost.value = response.result
- detailModalVisible.value = true
- } else {
- message.error('获取帖子详情失败:响应数据为空')
- }
- } catch (error) {
- notifyError(error, '获取帖子详情失败:' + error.message)
- }
- }
- const parseMedia = (mediaStr) => {
- if (!mediaStr || mediaStr === 'null' || mediaStr === 'undefined') return []
- // 如果已经是数组,直接返回
- if (Array.isArray(mediaStr)) {
- return mediaStr.filter(Boolean)
- }
- // 尝试解析JSON
- try {
- const parsed = JSON.parse(mediaStr)
- if (Array.isArray(parsed)) {
- return parsed.filter(Boolean)
- }
- return []
- } catch {
- // 如果不是JSON,按逗号分割
- if (typeof mediaStr === 'string') {
- return mediaStr.split(',').filter(item => item && item.trim())
- }
- return []
- }
- }
- const isImage = (url) => {
- if (url && typeof url === 'object') return url.type === 'image'
- return /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
- }
- const isVideo = (url) => {
- if (url && typeof url === 'object') return url.type === 'video'
- return /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(url)
- }
- // 操作按钮点击事件
- const onAction = (action, record) => {
- if (action.key === 'view') {
- viewDetail(record)
- }
- }
- </script>
- <style scoped>
- .poll-detail { margin-top: 20px; padding: 16px; border: 1px solid #eee; border-radius: 8px; }
- .poll-option-detail { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
- .poll-option-result { flex: 1; min-width: 0; }
- .posts-container {
- padding: 24px;
- }
- .post-title {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .title-text {
- font-weight: 500;
- color: var(--meow-color-primary);
- cursor: pointer;
- }
- .post-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- color: #666;
- }
- .meta-item {
- background: #f5f5f5;
- padding: 2px 6px;
- border-radius: 4px;
- }
- .post-content-wrapper {
- display: flex;
- flex-direction: column;
- gap: 8px;
- max-width: 200px;
- }
- .post-text {
- color: #666;
- line-height: 1.4;
- word-break: break-all;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .post-images {
- margin-top: 4px;
- }
- .image-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 2px;
- max-width: 180px;
- }
- .grid-image {
- object-fit: cover;
- border-radius: 4px;
- cursor: pointer;
- transition: transform 0.2s;
- display: block;
- }
- /* 确保a-image组件内的img元素不会拉伸变形 */
- .grid-image :deep(img) {
- object-fit: cover !important;
- width: 60px !important;
- height: 60px !important;
- }
- .grid-image :deep(.ant-image) {
- width: 60px !important;
- height: 60px !important;
- }
- .grid-image :deep(.ant-image-img) {
- object-fit: cover !important;
- }
- .grid-image:hover {
- transform: scale(1.05);
- }
- .user-info {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .user-details {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .user-id {
- font-size: 12px;
- color: #999;
- }
- .topic-info {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .topic-details {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .topic-name {
- font-weight: 500;
- }
- .topic-id {
- font-size: 12px;
- color: #999;
- }
- .post-content-detail {
- max-height: 200px;
- overflow-y: auto;
- line-height: 1.6;
- white-space: pre-wrap;
- }
- .media-section {
- border-top: 1px solid #f0f0f0;
- padding-top: 16px;
- }
- .media-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .media-item {
- border: 1px solid #d9d9d9;
- border-radius: 4px;
- overflow: hidden;
- }
- .statistics-cards .ant-card {
- text-align: center;
- }
- .table-operations {
- display: flex;
- align-items: center;
- }
- </style>
|