Files
quyun/frontend/admin/src/pages/PostPage.vue
2025-04-18 22:41:51 +08:00

489 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { postService } from '@/api/postService';
import { formatDate } from '@/utils/date';
import { getFileTypeByMimeCN } from "@/utils/filetype";
import { InputText } from 'primevue';
import Badge from 'primevue/badge';
import { userService } from '@/api/userService';
import Button from 'primevue/button';
import Column from 'primevue/column';
import ConfirmDialog from 'primevue/confirmdialog';
import DataTable from 'primevue/datatable';
import Dialog from 'primevue/dialog';
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);
const filters = ref({
global: { value: null, matchMode: 'contains' },
status: { value: null, matchMode: 'equals' },
mediaTypes: { value: null, matchMode: 'equals' }
});
// 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 = {
0: '草稿',
1: '发布',
};
// Transform assets to media types
const getMediaTypes = (assets) => {
return [...new Set(assets.map(asset => {
return getFileTypeByMimeCN(asset.Type)
}))];
};
// 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}`);
};
// Delete post
const confirmDelete = (post) => {
confirm.require({
message: `确定要删除 "${post.title}" 吗?`,
header: '确认删除',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => {
// call remote delete
postService.deletePost(post.id)
.then(() => {
// toast success
toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
fetchPosts();
})
.catch(error => {
console.error('Delete error:', error); // Debug log
toast.add({ severity: 'error', summary: '错误', detail: '删除文章失败', life: 3000 });
});
}
});
};
// Add these helper functions next to existing price-related functions
const getDiscountAmount = (price, discount) => {
return price * (100 - discount) / 100;
};
const getFinalPrice = (price, discount) => {
return price * 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: formatDate(post.created_at),
editedAt: formatDate(post.updated_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 = (assets) => {
return assets.map(asset => getFileTypeByMimeCN(asset.type)).join(', ');
};
// Add user selection dialog state and methods
const sendDialogVisible = ref(false);
const selectedPost = ref(null);
const selectedUser = ref(null);
// 修改用户列表相关变量
const users = ref({
items: [],
total: 0,
page: 1,
limit: 10
});
const userFirst = ref(0);
const userRows = ref(10);
const userLoading = ref(false);
const sendToUser = (post) => {
selectedPost.value = post;
sendDialogVisible.value = true;
searchUsers(''); // 初始加载用户列表
};
// 修改 searchUsers 函数
const searchUsers = async (query = '') => {
userLoading.value = true;
try {
const currentPage = (userFirst.value / userRows.value) + 1;
const response = await userService.getUsers({
page: currentPage,
limit: userRows.value,
keyword: userFilter.value
});
users.value = response.data;
} catch (error) {
toast.add({
severity: 'error',
summary: '错误',
detail: '加载用户列表失败',
life: 3000
});
} finally {
userLoading.value = false;
}
};
// 修改用户分页事件处理
const onUserPage = (event) => {
userFirst.value = event.first;
userRows.value = event.rows;
searchUsers();
};
// 添加用户选择相关变量
const userFilter = ref('');
const userSearchTimeout = ref(null);
// 修改用户搜索处理函数
const onUserSearch = (event) => {
if (userSearchTimeout.value) {
clearTimeout(userSearchTimeout.value);
}
userSearchTimeout.value = setTimeout(() => {
userFirst.value = 0; // 重置到第一页
searchUsers();
}, 300);
};
const handleSendConfirm = async () => {
if (!selectedUser.value) {
toast.add({
severity: 'warn',
summary: '提示',
detail: '请选择用户',
life: 3000
});
return;
}
try {
await postService.sendTo(selectedPost.value.id, selectedUser.value.id);
toast.add({
severity: 'success',
summary: '成功',
detail: '赠送成功',
life: 3000
});
sendDialogVisible.value = false;
selectedUser.value = null;
selectedPost.value = null;
} catch (error) {
toast.add({
severity: 'error',
summary: '错误',
detail: '赠送失败',
life: 3000
});
}
};
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Add user selection dialog -->
<Dialog v-model:visible="sendDialogVisible" modal header="选择用户" :style="{ width: '80vw' }">
<div class="flex flex-col gap-4">
<div class="mb-4">
<span class="font-bold">文章</span>
{{ selectedPost?.title }}
</div>
<div class="pb-4">
<InputText v-model="userFilter" placeholder="搜索用户..." class="w-full" @input="onUserSearch" />
</div>
<!-- 修改 Dialog 中的 DataTable 部分 -->
<DataTable v-model:selection="selectedUser" :value="users.items" selectionMode="single"
:loading="userLoading" :paginator="true" :rows="userRows" :totalRecords="users.total" :lazy="true"
:first="userFirst" @page="onUserPage" dataKey="id" class="p-datatable-sm" responsiveLayout="scroll"
style="max-height: 60vh" scrollable>
<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 selectionMode="single" style="width: 3rem" />
<Column field="username" header="用户名">
<template #body="{ data }">
<div class="flex items-center space-x-3">
<div class="avatar">
<div class="mask mask-squircle w-12 h-12">
<img :src="data.avatar" :alt="data.username" />
</div>
</div>
<div>
<div class="font-bold">{{ data.username }}</div>
</div>
</div>
</template>
</Column>
<Column field="phone" header="手机号" />
<Column field="status" header="状态">
<template #body="{ data }">
<Badge :value="data.status === 0 ? '活跃' : '禁用'"
:severity="data.status === 0 ? 'success' : 'danger'" />
</template>
</Column>
<Column field="created_at" header="注册时间">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" @click="sendDialogVisible = false" class="p-button-text" />
<Button label="确认赠送" icon="pi pi-check" @click="handleSendConfirm" :disabled="!selectedUser" autofocus
severity="primary" />
</template>
</Dialog>
<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="filters.value" 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="flex flex-col">
<span class="text-gray-500">原价: {{ formatPrice(data.price) }}</span>
<span class="text-orange-500">优惠: -{{ formatPrice(getDiscountAmount(data.price,
data.discount)) }}</span>
<span class="font-bold">实付: {{ formatPrice(getFinalPrice(data.price, data.discount))
}}</span>
</div>
</template>
</Column>
<Column field="bought_count" header="购买数量" sortable>
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-gray-500">{{ data.bought_count }}</span>
</div>
</template>
</Column>
<Column field="updated_at" header="时间信息" sortable>
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-gray-500">更新: {{ formatDate(data.updated_at) }}</span>
<span class="text-gray-400">创建: {{ formatDate(data.created_at) }}</span>
</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="assets" header="媒体类型" sortable>
<template #body="{ data }">
<div class="text-sm text-gray-900">{{ formatMediaTypes(data.assets) }}</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-shopping-cart" rounded text severity="info" @click="sendToUser(data)"
aria-label="赠送" />
<Button icon="pi pi-pencil" rounded text severity="info" @click="navigateToEditPost(data)"
aria-label="编辑" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<style scoped>
/* Additional styling if needed */
</style>