czq
parent
f628c6dece
commit
95b833a711
@ -0,0 +1,135 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import postApi from '@/api/post';
|
||||
import type { PostItem, CategoryItem } from '@/api/post';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
export interface PostState {
|
||||
posts: PostItem[];
|
||||
currentPost: PostItem | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPosts: number;
|
||||
totalPages: number;
|
||||
|
||||
categories: CategoryItem[];
|
||||
loadingCategories: boolean;
|
||||
errorCategories: string | null;
|
||||
selectedCategoryId: number | null;
|
||||
}
|
||||
|
||||
export const usePostStore = defineStore('post', {
|
||||
state: (): PostState => ({
|
||||
posts: [],
|
||||
currentPost: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalPosts: 0,
|
||||
totalPages: 0,
|
||||
|
||||
categories: [],
|
||||
loadingCategories: false,
|
||||
errorCategories: null,
|
||||
selectedCategoryId: null,
|
||||
}),
|
||||
actions: {
|
||||
async fetchPosts(params: { pageNum?: number; pageSize?: number } = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const pageNum = params.pageNum || this.currentPage;
|
||||
const pageSize = params.pageSize || this.pageSize;
|
||||
const categoryId = this.selectedCategoryId; // Use the stored selectedCategoryId
|
||||
|
||||
const apiParams: { pageNum: number; pageSize: number; categoryId?: number } = { pageNum, pageSize };
|
||||
if (categoryId !== null) {
|
||||
apiParams.categoryId = categoryId;
|
||||
}
|
||||
|
||||
const response = await postApi.getPosts(apiParams);
|
||||
|
||||
if (response && response.data && Array.isArray(response.data.list)) {
|
||||
this.posts = response.data.list;
|
||||
this.totalPosts = response.data.total;
|
||||
this.totalPages = response.data.pages;
|
||||
this.currentPage = response.data.pageNum; // Ensure backend returns this
|
||||
this.pageSize = response.data.pageSize; // Ensure backend returns this
|
||||
} else {
|
||||
console.error('Unexpected response structure for posts:', response);
|
||||
this.posts = [];
|
||||
this.totalPosts = 0;
|
||||
this.totalPages = 0;
|
||||
// Do not reset currentPage and pageSize here unless intended,
|
||||
// as they might be needed for subsequent fetches if only list is malformed.
|
||||
throw new Error('帖子数据格式不正确');
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.error = error.message || '获取帖子列表失败';
|
||||
// Potentially keep existing posts if fetch fails, or clear them:
|
||||
// this.posts = [];
|
||||
// this.totalPosts = 0;
|
||||
// this.totalPages = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPostDetail(id: number) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.currentPost = null;
|
||||
try {
|
||||
const response = await postApi.getPostDetail(id);
|
||||
// The API returns code 200 for success, and post data is directly in response.data
|
||||
if (response && response.code === 200 && response.data) {
|
||||
this.currentPost = response.data;
|
||||
} else {
|
||||
// Construct a more informative error or use a default
|
||||
const errorMessage = response?.message || (response?.data?.toString() ? `错误: ${response.data.toString()}` : '获取帖子详情失败');
|
||||
console.error('Failed to fetch post detail:', response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ensure this.error is set from error.message, and ElMessage shows this.error or a default.
|
||||
this.error = error.message || '加载帖子详情时发生未知错误';
|
||||
ElMessage.error(this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchCategories() {
|
||||
this.loadingCategories = true;
|
||||
this.errorCategories = null;
|
||||
try {
|
||||
const response = await postApi.getCategories();
|
||||
// response.data is an object like { total: number, list: CategoryItem[] }
|
||||
// We need to access the 'list' property for the actual categories array.
|
||||
if (response && response.data && Array.isArray(response.data.list)) {
|
||||
this.categories = response.data.list;
|
||||
} else {
|
||||
// Handle cases where the structure is not as expected, though API seems to return it correctly.
|
||||
console.error('Unexpected response structure for categories:', response);
|
||||
this.categories = []; // Default to empty array to prevent further errors
|
||||
throw new Error('分类数据格式不正确');
|
||||
}
|
||||
this.loadingCategories = false;
|
||||
} catch (error: any) {
|
||||
this.errorCategories = error.message || '获取分类失败';
|
||||
} finally {
|
||||
this.loadingCategories = false;
|
||||
}
|
||||
},
|
||||
|
||||
async selectCategory(categoryId: number | null) {
|
||||
if (this.selectedCategoryId !== categoryId) {
|
||||
this.selectedCategoryId = categoryId;
|
||||
this.currentPage = 1;
|
||||
await this.fetchPosts();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="post-detail-view">
|
||||
<el-button @click="goBack" class="back-button" :icon="ArrowLeft">返回列表</el-button>
|
||||
|
||||
<el-skeleton :rows="8" animated v-if="postStore.loading && !postStore.currentPost" />
|
||||
|
||||
<el-alert
|
||||
v-if="postStore.error && !postStore.currentPost"
|
||||
:title="`获取帖子详情失败: ${postStore.error}`"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-card v-if="postStore.currentPost" class="post-content-card">
|
||||
<template #header>
|
||||
<h1>{{ postStore.currentPost.title }}</h1>
|
||||
<div class="post-meta-detail">
|
||||
<span>作者: {{ postStore.currentPost.nickname }}</span>
|
||||
<span>分类: {{ postStore.currentPost.categoryName }}</span>
|
||||
<span>发布于: {{ formatDate(postStore.currentPost.createdAt) }}</span>
|
||||
<span v-if="postStore.currentPost.updatedAt && postStore.currentPost.updatedAt !== postStore.currentPost.createdAt">
|
||||
更新于: {{ formatDate(postStore.currentPost.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="post-body" v-html="postStore.currentPost.content"></div>
|
||||
|
||||
<el-divider />
|
||||
<div class="post-stats">
|
||||
<span><el-icon><View /></el-icon> {{ postStore.currentPost.viewCount }} 次浏览</span>
|
||||
<span><el-icon><Pointer /></el-icon> {{ postStore.currentPost.likeCount }} 个点赞</span>
|
||||
<span><el-icon><ChatDotRound /></el-icon> {{ postStore.currentPost.commentCount }} 条评论</span>
|
||||
<el-tag v-if="postStore.currentPost.isLiked" type="success" effect="light">已点赞</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Placeholder for comments section -->
|
||||
<el-card class="comments-section" v-if="postStore.currentPost">
|
||||
<template #header>
|
||||
<h3>评论区</h3>
|
||||
</template>
|
||||
<el-empty description="暂无评论,敬请期待!"></el-empty>
|
||||
<!-- Actual comments list and form will go here later -->
|
||||
</el-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { usePostStore } from '@/stores/postStore';
|
||||
import { ElMessage, ElIcon, ElButton, ElCard, ElSkeleton, ElAlert, ElDivider, ElTag, ElEmpty } from 'element-plus';
|
||||
import { View, Pointer, ChatDotRound, ArrowLeft } from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const postStore = usePostStore();
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/'); // 返回到论坛首页
|
||||
};
|
||||
|
||||
const loadPostDetails = (id: string | number) => {
|
||||
const postId = Number(id);
|
||||
if (isNaN(postId)) {
|
||||
ElMessage.error('无效的帖子ID');
|
||||
router.push('/forum'); // Redirect if ID is invalid
|
||||
return;
|
||||
}
|
||||
postStore.fetchPostDetail(postId);
|
||||
};
|
||||
|
||||
// Fetch post details when the component is mounted and when the route param changes
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
loadPostDetails(route.params.id as string);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(newId) => {
|
||||
if (newId && newId !== postStore.currentPost?.id?.toString()) {
|
||||
loadPostDetails(newId as string);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-detail-view {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.post-content-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-meta-detail {
|
||||
font-size: 0.9em;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.post-body {
|
||||
line-height: 1.8;
|
||||
color: var(--el-text-color-primary);
|
||||
/* Add more styling if content is rich text (e.g., from a WYSIWYG editor) */
|
||||
}
|
||||
|
||||
.post-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.post-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.post-stats span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="post-list-view">
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="filter-controls">
|
||||
<div class="filter-left">
|
||||
<el-select
|
||||
v-model="selectedCategoryComputed"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
@clear="clearCategorySelection"
|
||||
style="width: 240px;"
|
||||
>
|
||||
<el-option label="全部分类" :value="null"></el-option>
|
||||
<el-option
|
||||
v-for="category in postStore.categories"
|
||||
:key="category.id"
|
||||
:label="category.name"
|
||||
:value="category.id"
|
||||
></el-option>
|
||||
</el-select>
|
||||
<el-alert v-if="postStore.loadingCategories" title="正在加载分类..." type="info" :closable="false" show-icon class="status-alert"></el-alert>
|
||||
<el-alert v-if="postStore.errorCategories" :title="`分类加载失败: ${postStore.errorCategories}`" type="error" :closable="false" show-icon class="status-alert"></el-alert>
|
||||
</div>
|
||||
|
||||
<div class="filter-right">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Edit"
|
||||
@click="createNewPost"
|
||||
>
|
||||
发布帖子
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-skeleton :rows="5" animated v-if="postStore.loading && postStore.posts.length === 0" />
|
||||
|
||||
<el-alert
|
||||
v-if="postStore.error && postStore.posts.length === 0"
|
||||
:title="`获取帖子列表失败: ${postStore.error}`"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div v-if="!postStore.loading && postStore.posts.length === 0 && !postStore.error" class="empty-state">
|
||||
<el-empty description="暂无帖子,敬请期待!"></el-empty>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-if="postStore.posts.length > 0">
|
||||
<el-col :span="24" v-for="post in postStore.posts" :key="post.id" class="post-item-col">
|
||||
<el-card class="post-item-card" shadow="hover" @click="navigateToPostDetail(post.id)">
|
||||
<template #header>
|
||||
<div class="post-title">{{ post.title }}</div>
|
||||
</template>
|
||||
<div class="post-summary">{{ post.summary || '暂无摘要' }}</div>
|
||||
<el-divider></el-divider>
|
||||
<div class="post-meta">
|
||||
<span><el-icon><User /></el-icon> {{ post.nickname }}</span>
|
||||
<span><el-icon><FolderOpened /></el-icon> {{ post.categoryName }}</span>
|
||||
<span><el-icon><View /></el-icon> {{ post.viewCount }}</span>
|
||||
<span><el-icon><Pointer /></el-icon> {{ post.likeCount }}</span>
|
||||
<span><el-icon><ChatDotRound /></el-icon> {{ post.commentCount }}</span>
|
||||
<span><el-icon><Clock /></el-icon> {{ formatDate(post.createdAt) }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination-container" v-if="postStore.totalPages > 1">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
:total="postStore.totalPosts"
|
||||
:page-size="postStore.pageSize"
|
||||
:current-page="postStore.currentPage"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePostStore } from '@/stores/postStore';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { ElMessage, ElIcon, ElCard, ElSkeleton, ElAlert, ElRow, ElCol, ElDivider, ElPagination, ElEmpty, ElSelect, ElOption, ElButton } from 'element-plus';
|
||||
import { User, FolderOpened, View, Pointer, ChatDotRound, Clock, Edit } from '@element-plus/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const postStore = usePostStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// Computed property to two-way bind el-select with store's selectedCategoryId
|
||||
// and trigger store action on change.
|
||||
const selectedCategoryComputed = computed({
|
||||
get: () => postStore.selectedCategoryId,
|
||||
set: (value) => {
|
||||
// 将分类值传递给store的selectCategory方法
|
||||
console.log('选择分类:', value);
|
||||
postStore.selectCategory(value);
|
||||
}
|
||||
});
|
||||
|
||||
const clearCategorySelection = () => {
|
||||
// 手动强制清除分类并重新获取帖子
|
||||
postStore.selectCategory(null);
|
||||
postStore.fetchPosts({ pageNum: 1 });
|
||||
console.log('清除分类选择');
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const navigateToPostDetail = (postId: number) => {
|
||||
router.push({ name: 'PostDetail', params: { id: postId.toString() } });
|
||||
};
|
||||
|
||||
// 创建新帖子
|
||||
const createNewPost = () => {
|
||||
if (userStore.isLoggedIn) {
|
||||
router.push({ name: 'CreatePost' });
|
||||
} else {
|
||||
ElMessage.warning('请先登录');
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: '/create-post' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
postStore.fetchPosts({ pageNum: page });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await postStore.fetchCategories(); // Fetch categories first
|
||||
// Fetch posts, it will use the default selectedCategoryId (null) from store initially
|
||||
// or if persisted from a previous session if store has persistence.
|
||||
postStore.fetchPosts({ pageNum: postStore.currentPage, pageSize: postStore.pageSize });
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-list-view {
|
||||
padding: 20px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px; /* Reduced padding for a more compact look */
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status-alert {
|
||||
flex-grow: 1;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.post-item-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.post-item-card {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.post-item-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-summary {
|
||||
font-size: 0.95em;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
min-height: 40px; /* Ensure some space even if summary is short */
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 20px; /* row-gap column-gap */
|
||||
font-size: 0.85em;
|
||||
color: var(--el-text-color-secondary);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px; /* Give some height to the empty state */
|
||||
}
|
||||
</style>
|
@ -1,8 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in new issue