319 lines
11 KiB
Vue
319 lines
11 KiB
Vue
<script setup>
|
|
import { postService } from '@/api/postService'; // Assuming you have a postService for API calls
|
|
import { InputText } from 'primevue';
|
|
import Badge from 'primevue/badge';
|
|
|
|
import Button from 'primevue/button';
|
|
import Column from 'primevue/column';
|
|
import ConfirmDialog from 'primevue/confirmdialog';
|
|
import DataTable from 'primevue/datatable';
|
|
import Dropdown from 'primevue/dropdown';
|
|
import ProgressSpinner from 'primevue/progressspinner';
|
|
import Toast from 'primevue/toast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
// Import useRouter for navigation
|
|
const router = useRouter();
|
|
const confirm = useConfirm();
|
|
const toast = useToast();
|
|
|
|
// Post statuses for filtering
|
|
const statusOptions = ref([
|
|
{ name: '所有状态', value: null },
|
|
{ name: '已发布', value: '已发布' },
|
|
{ name: '草稿', value: '草稿' },
|
|
{ name: '已下架', value: '已下架' }
|
|
]);
|
|
|
|
// Media types for filtering
|
|
const mediaTypeOptions = ref([
|
|
{ name: '所有类型', value: null },
|
|
{ name: '文章', value: '文章' },
|
|
{ name: '视频', value: '视频' },
|
|
{ name: '音频', value: '音频' }
|
|
]);
|
|
|
|
const globalFilterValue = ref('');
|
|
const loading = ref(false);
|
|
const searchTimeout = ref(null);
|
|
|
|
// Sample data - in a real app, this would come from an API
|
|
const posts = ref([]);
|
|
|
|
// Pagination state
|
|
const first = ref(0); // 当前页起始索引
|
|
const rows = ref(10); // 每页显示数量
|
|
const total = ref(0); // 总记录数
|
|
|
|
// Status mapping
|
|
const statusMap = {
|
|
1: '已发布',
|
|
2: '草稿',
|
|
3: '已下架'
|
|
};
|
|
|
|
// Transform assets to media types
|
|
const getMediaTypes = (assets) => {
|
|
return [...new Set(assets.map(asset => {
|
|
switch (asset.type) {
|
|
case 'audio': return '音频';
|
|
case 'video': return '视频';
|
|
default: return '文章';
|
|
}
|
|
}))];
|
|
};
|
|
|
|
// Navigate to post creation page
|
|
const navigateToCreatePost = () => {
|
|
router.push('/posts/create');
|
|
};
|
|
|
|
// Navigate to post edit page
|
|
const navigateToEditPost = (post) => {
|
|
router.push(`/posts/edit/${post.id}`);
|
|
};
|
|
|
|
// View post details
|
|
const viewPost = (post) => {
|
|
// In a real app, this would navigate to a post detail page
|
|
toast.add({ severity: 'info', summary: '查看', detail: `查看文章: ${post.title}`, life: 3000 });
|
|
};
|
|
|
|
// Delete post
|
|
const confirmDelete = (post) => {
|
|
confirm.require({
|
|
message: `确定要删除 "${post.title}" 吗?`,
|
|
header: '确认删除',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
accept: () => {
|
|
// In a real app, you would call an API to delete the post
|
|
posts.value = posts.value.filter(p => p.id !== post.id);
|
|
toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Format datetime to YY/MM/DD HH:mm:ss
|
|
const formatDateTime = (dateStr) => {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
}).replace(/\//g, '-');
|
|
};
|
|
|
|
// Calculate price after discount
|
|
const calculateDiscountPrice = (price, discount) => {
|
|
if (!discount) return price;
|
|
return price * (1 - discount / 100);
|
|
};
|
|
|
|
// Fetch posts data with pagination and search
|
|
const fetchPosts = async () => {
|
|
loading.value = true;
|
|
try {
|
|
// Calculate current page (1-based) from first and rows
|
|
const currentPage = (first.value / rows.value) + 1;
|
|
console.log('Fetching page:', currentPage); // Debug log
|
|
|
|
const response = await postService.getPosts({
|
|
page: currentPage,
|
|
limit: rows.value,
|
|
keyword: globalFilterValue.value
|
|
});
|
|
|
|
posts.value = response.data.items.map(post => ({
|
|
...post,
|
|
status: statusMap[post.status] || '未知',
|
|
mediaTypes: getMediaTypes(post.assets),
|
|
price: post.price / 100,
|
|
publishedAt: formatDateTime(post.created_at),
|
|
viewCount: post.views,
|
|
likes: post.likes
|
|
}));
|
|
total.value = response.data.total;
|
|
} catch (error) {
|
|
console.error('Fetch error:', error); // Debug log
|
|
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Handle page change
|
|
const onPage = (event) => {
|
|
console.log('Page event:', event); // Debug log
|
|
first.value = event.first;
|
|
rows.value = event.rows;
|
|
fetchPosts();
|
|
};
|
|
|
|
// Handle search with debounce
|
|
const onSearch = (event) => {
|
|
if (searchTimeout.value) {
|
|
clearTimeout(searchTimeout.value);
|
|
}
|
|
|
|
searchTimeout.value = setTimeout(() => {
|
|
first.value = 0; // Reset to first page when searching
|
|
fetchPosts();
|
|
}, 300);
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchPosts();
|
|
});
|
|
|
|
// Status badge severity mapping
|
|
const getBadgeSeverity = (status) => {
|
|
const map = {
|
|
'已发布': 'success',
|
|
'草稿': 'warning',
|
|
'已下架': 'danger'
|
|
};
|
|
return map[status] || 'info';
|
|
};
|
|
|
|
// Format price to display ¥ symbol
|
|
const formatPrice = (price) => {
|
|
return `¥${price.toFixed(2)}`;
|
|
};
|
|
|
|
// Format media types to display as comma-separated string
|
|
const formatMediaTypes = (mediaTypes) => {
|
|
return mediaTypes.join(', ');
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Toast />
|
|
<ConfirmDialog />
|
|
|
|
<div class="w-full">
|
|
<div class="flex justify-between items-center mb-6 gap-4">
|
|
<h1 class="text-2xl font-semibold text-gray-800 text-nowrap">文章列表</h1>
|
|
|
|
<Button class="text-nowrap !px-8" icon="pi pi-plus" label="创建文章" severity="primary"
|
|
@click="navigateToCreatePost" />
|
|
</div>
|
|
|
|
<!-- Posts Table -->
|
|
<div class="card mt-10">
|
|
<div class="pb-10 flex">
|
|
<InputText v-model="globalFilterValue" placeholder="搜索文章..." class="flex-1" @input="onSearch" />
|
|
</div>
|
|
|
|
<DataTable v-model:filters="filters" :value="posts" :paginator="true" :rows="rows" :totalRecords="total"
|
|
:loading="loading" :lazy="true" :first="first" @page="onPage"
|
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
|
:rowsPerPageOptions="[10, 20, 50]"
|
|
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" dataKey="id"
|
|
:globalFilterFields="['title', 'description', 'status']"
|
|
:filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort
|
|
class="p-datatable-sm" responsiveLayout="scroll">
|
|
|
|
<template #empty>
|
|
<div class="text-center p-4">未找到文章。</div>
|
|
</template>
|
|
|
|
<template #loading>
|
|
<div class="flex flex-col items-center justify-center p-4">
|
|
<ProgressSpinner style="width:50px;height:50px" />
|
|
<span class="mt-2">加载文章数据...</span>
|
|
</div>
|
|
</template>
|
|
|
|
<Column field="title" header="标题" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm font-medium text-gray-900">
|
|
{{ data.title }}
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="price" header="价格" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm text-gray-900">
|
|
<span class="line-through text-gray-500" v-if="data.discount">
|
|
{{ formatPrice(data.price) }}
|
|
</span>
|
|
<span :class="{ 'ml-2': data.discount }">
|
|
{{ formatPrice(calculateDiscountPrice(data.price, data.discount)) }}
|
|
</span>
|
|
<span v-if="data.discount" class="ml-2 text-red-500">
|
|
(-{{ data.discount }}%)
|
|
</span>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="publishedAt" header="发布时间" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm text-gray-900">{{ data.publishedAt }}</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="status" header="发布状态" sortable>
|
|
<template #body="{ data }">
|
|
<Badge :value="data.status" :severity="getBadgeSeverity(data.status)" />
|
|
</template>
|
|
<template #filter="{ filterModel, filterCallback }">
|
|
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions"
|
|
optionLabel="name" optionValue="value" placeholder="所有状态" class="p-column-filter"
|
|
showClear />
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="mediaTypes" header="媒体类型" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm text-gray-900">{{ formatMediaTypes(data.mediaTypes) }}</div>
|
|
</template>
|
|
<template #filter="{ filterModel, filterCallback }">
|
|
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="mediaTypeOptions"
|
|
optionLabel="name" optionValue="value" placeholder="所有类型" class="p-column-filter"
|
|
showClear />
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="viewCount" header="观看次数" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm text-gray-500">{{ data.viewCount }}</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="likes" header="点赞数" sortable>
|
|
<template #body="{ data }">
|
|
<div class="text-sm text-gray-500">{{ data.likes }}</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="操作" :exportable="false" style="min-width:8rem">
|
|
<template #body="{ data }">
|
|
<div class="flex justify-center space-x-2">
|
|
<Button icon="pi pi-pencil" rounded text severity="info" @click="navigateToEditPost(data)"
|
|
aria-label="编辑" />
|
|
<Button icon="pi pi-eye" rounded text severity="secondary" @click="viewPost(data)"
|
|
aria-label="查看" />
|
|
<Button icon="pi pi-trash" rounded text severity="danger" @click="confirmDelete(data)"
|
|
aria-label="删除" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Additional styling if needed */
|
|
</style>
|