489 lines
17 KiB
Vue
489 lines
17 KiB
Vue
<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>
|