2991692032 2 days ago
parent f628c6dece
commit 95b833a711

@ -4,7 +4,8 @@ import { get, post as httpPost, put, del } from './request';
export interface PostItem { export interface PostItem {
id: number; id: number;
title: string; title: string;
content: string; summary?: string; // Added: for post list item summary
content: string; // Existing: for post detail content
userId: number; userId: number;
nickname: string; nickname: string;
avatar: string; avatar: string;
@ -15,6 +16,13 @@ export interface PostItem {
commentCount: number; commentCount: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isLiked?: boolean; // Added: for post detail, if current user liked it
}
export interface CategoryItem {
id: number;
name: string;
description?: string;
} }
export interface CreatePostParams { export interface CreatePostParams {
@ -26,8 +34,8 @@ export interface CreatePostParams {
// API方法 // API方法
export default { export default {
// 获取帖子列表 // 获取帖子列表
getPosts(params: { page?: number; size?: number; category?: number; sort?: string }) { getPosts(params: { pageNum?: number; pageSize?: number; categoryId?: number; sort?: string }) {
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', params); return get<{ code: number; data: { total: number; list: PostItem[]; pages: number; pageNum: number; pageSize: number } }>('/posts', params);
}, },
// 获取帖子详情 // 获取帖子详情
@ -59,5 +67,10 @@ export default {
getUserPosts(userId?: number) { getUserPosts(userId?: number) {
const params = userId ? { userId } : {}; const params = userId ? { userId } : {};
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', params); return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', params);
},
// 获取所有帖子分类
getCategories() {
return get<{ code: number; message: string; data: { list: CategoryItem[], total: number } }>('/categories');
} }
}; };

@ -2,16 +2,20 @@ import { get, post, put } from './request';
// 用户接口类型定义 // 用户接口类型定义
export interface UserInfo { export interface UserInfo {
id: number;
username: string; username: string;
email: string; email: string;
avatar?: string; avatar?: string;
gender?: number;
bio?: string; bio?: string;
gender?: number;
birthday?: string; birthday?: string;
studentId?: string; studentId?: string;
department?: string; department?: string;
major?: string; major?: string;
grade?: string; grade?: string;
points?: number;
role?: number;
isVerified?: number;
} }
export interface LoginParams { export interface LoginParams {
@ -23,7 +27,6 @@ export interface RegisterParams {
email: string; email: string;
password: string; password: string;
username?: string; username?: string;
nickname?: string;
studentId?: string; studentId?: string;
department?: string; department?: string;
major?: string; major?: string;
@ -44,7 +47,9 @@ export interface UpdateProfileParams {
username?: string; username?: string;
bio?: string; bio?: string;
gender?: number; gender?: number;
birthday?: string; department?: string;
major?: string;
grade?: string;
} }
export interface UpdatePasswordParams { export interface UpdatePasswordParams {

@ -2,18 +2,19 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
import { HomeFilled, User, Document, Message, Setting, ArrowLeft } from '@element-plus/icons-vue';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
// // -
const menuItems = [ const menuItems = [
{ name: 'Home', title: '个人主页', icon: 'home' }, { name: 'PersonalHome', title: '个人主页', icon: HomeFilled, path: '/personal/home' },
{ name: 'AccountManager', title: '账号管理', icon: 'user' }, { name: 'AccountManager', title: '账号管理', icon: User, path: '/personal/account' },
{ name: 'Posts', title: '我的帖子', icon: 'document' }, { name: 'MyPosts', title: '我的帖子', icon: Document, path: '/personal/posts' },
{ name: 'Messages', title: '消息中心', icon: 'message' }, { name: 'Messages', title: '消息中心', icon: Message, path: '/personal/messages' },
{ name: 'Settings', title: '设置', icon: 'setting' } { name: 'Settings', title: '设置', icon: Setting, path: '/personal/settings' }
]; ];
// //
@ -22,13 +23,18 @@ const activeIndex = ref(0);
// //
const setActive = (index: number) => { const setActive = (index: number) => {
activeIndex.value = index; activeIndex.value = index;
router.push({ name: menuItems[index].name }); router.push(menuItems[index].path);
};
//
const backToForum = () => {
router.push('/');
}; };
// //
onMounted(() => { onMounted(() => {
const currentRouteName = route.name as string; const currentPath = route.path;
const index = menuItems.findIndex(item => item.name === currentRouteName); const index = menuItems.findIndex(item => currentPath.includes(item.path));
if (index !== -1) { if (index !== -1) {
activeIndex.value = index; activeIndex.value = index;
} }
@ -37,58 +43,59 @@ onMounted(() => {
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
userStore.fetchUserInfo(); userStore.fetchUserInfo();
} else { } else {
// //
router.push('/login'); router.push({ path: '/login', query: { redirect: route.fullPath } });
} }
}); });
// 退
const logout = () => {
userStore.logout();
router.push('/login');
};
</script> </script>
<template> <template>
<div class="personal-layout"> <div class="personal-layout">
<!-- 侧边栏 --> <!-- 返回论坛按钮 -->
<div class="sidebar"> <div class="back-bar">
<div class="sidebar-header"> <button class="back-btn" @click="backToForum">
<div class="avatar"> <el-icon><ArrowLeft /></el-icon>
<img :src="userStore.userInfo?.avatar || '/images/默认头像.jpg'" alt="用户头像"> <span>返回论坛</span>
</button>
<h1 class="page-title">个人中心</h1>
</div>
<div class="layout-container">
<!-- 侧边栏 -->
<div class="sidebar">
<div class="sidebar-header">
<div class="avatar">
<img :src="userStore.userInfo?.avatar || '/images/默认头像.jpg'" alt="用户头像">
</div>
<div class="user-info">
<div class="nickname">{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}</div>
<div class="username">{{ userStore.userInfo?.username }}</div>
</div>
</div> </div>
<div class="username">{{ userStore.userInfo?.username || '用户' }}</div>
</div>
<ul class="menu"> <ul class="menu">
<li <li
v-for="(item, index) in menuItems" v-for="(item, index) in menuItems"
:key="index" :key="index"
:class="{ active: activeIndex === index }" :class="{ active: activeIndex === index }"
@click="setActive(index)" @click="setActive(index)"
> >
<div class="menu-item"> <div class="menu-item">
<div class="icon"> <div class="icon">
<el-icon> <el-icon>
<component :is="item.icon"></component> <component :is="item.icon"></component>
</el-icon> </el-icon>
</div>
<div class="title">{{ item.title }}</div>
</div> </div>
<div class="title">{{ item.title }}</div> </li>
</div> </ul>
</li>
</ul>
<div class="sidebar-footer">
<button class="logout-btn" @click="logout">
<el-icon><switch-button /></el-icon>
<span>退出登录</span>
</button>
</div> </div>
</div>
<!-- 主内容区 --> <!-- 主内容区 -->
<div class="main-content"> <div class="main-content">
<router-view /> <router-view />
</div>
</div> </div>
</div> </div>
</template> </template>
@ -96,40 +103,81 @@ const logout = () => {
<style scoped> <style scoped>
.personal-layout { .personal-layout {
display: flex; display: flex;
flex-direction: column;
width: 100%; width: 100%;
height: 100vh; min-height: 100vh;
overflow: hidden; background-color: var(--bg-color);
} }
.sidebar { .back-bar {
width: var(--sidebar-width); display: flex;
height: 100%; align-items: center;
padding: 12px 24px;
background-color: var(--secondary-color); background-color: var(--secondary-color);
transition: width var(--transition-slow); border-bottom: 1px solid rgba(0, 0, 0, 0.1);
overflow: hidden; }
.back-btn {
display: flex; display: flex;
flex-direction: column; align-items: center;
z-index: 100; padding: 8px 16px;
background-color: transparent;
border: none;
border-radius: var(--border-radius-sm);
color: var(--primary-color);
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-normal);
}
.back-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.back-btn span {
margin-left: 8px;
} }
.sidebar:hover { .page-title {
width: var(--sidebar-width-expanded); margin-left: 16px;
font-size: 1.2rem;
color: var(--text-primary);
}
.layout-container {
display: flex;
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 24px;
gap: 24px;
}
.sidebar {
width: 260px;
background-color: white;
border-radius: var(--border-radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
} }
.sidebar-header { .sidebar-header {
padding: var(--spacing-lg); padding: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: var(--spacing-xl); border-bottom: 1px solid rgba(0, 0, 0, 0.06);
} }
.avatar { .avatar {
width: 60px; width: 80px;
height: 60px; height: 80px;
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
overflow: hidden; overflow: hidden;
margin-bottom: var(--spacing-md); margin-bottom: 12px;
border: 3px solid var(--primary-light); border: 3px solid var(--primary-light);
} }
@ -139,35 +187,46 @@ const logout = () => {
object-fit: cover; object-fit: cover;
} }
.username { .user-info {
text-align: center;
}
.nickname {
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 600;
white-space: nowrap; font-size: 1.1rem;
margin-bottom: 4px;
}
.username {
color: var(--text-secondary);
font-size: 0.9rem;
} }
.menu { .menu {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 var(--spacing-sm); padding: 12px;
} }
.menu li { .menu li {
position: relative; position: relative;
margin-bottom: var(--spacing-md); margin-bottom: 8px;
list-style: none;
cursor: pointer; cursor: pointer;
} }
.menu-item { .menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: var(--spacing-md); padding: 12px 16px;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
transition: background-color var(--transition-normal); transition: all var(--transition-normal);
} }
.menu-item:hover { .menu-item:hover {
background-color: rgba(255, 255, 255, 0.5); background-color: rgba(0, 0, 0, 0.04);
} }
.menu li.active .menu-item { .menu li.active .menu-item {
@ -181,83 +240,48 @@ const logout = () => {
align-items: center; align-items: center;
width: 24px; width: 24px;
height: 24px; height: 24px;
margin-right: var(--spacing-md); margin-right: 12px;
} }
.title { .title {
white-space: nowrap; white-space: nowrap;
} font-weight: 500;
.sidebar-footer {
padding: var(--spacing-lg);
display: flex;
justify-content: center;
}
.logout-btn {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-md);
background-color: rgba(255, 255, 255, 0.5);
color: var(--text-primary);
transition: background-color var(--transition-normal);
}
.logout-btn:hover {
background-color: rgba(255, 255, 255, 0.8);
}
.logout-btn .el-icon {
margin-right: var(--spacing-sm);
} }
.main-content { .main-content {
flex: 1; flex: 1;
padding: var(--spacing-xl); background-color: white;
border-radius: var(--border-radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
overflow-y: auto; overflow-y: auto;
background-color: var(--bg-secondary);
} }
/* 响应式设计 */ @media screen and (max-width: 768px) {
@media (max-width: 768px) { .layout-container {
.personal-layout {
flex-direction: column; flex-direction: column;
padding: 16px;
} }
.sidebar { .sidebar {
width: 100%; width: 100%;
height: auto;
flex-direction: row;
padding: var(--spacing-sm);
}
.sidebar:hover {
width: 100%;
} }
.sidebar-header { .sidebar-header {
margin-bottom: 0;
padding: var(--spacing-sm);
}
.menu {
flex-direction: row; flex-direction: row;
padding: 0; align-items: center;
overflow-x: auto; text-align: left;
} }
.menu li { .avatar {
margin-right: var(--spacing-md); width: 60px;
height: 60px;
margin-bottom: 0; margin-bottom: 0;
margin-right: 16px;
} }
.sidebar-footer { .user-info {
padding: var(--spacing-sm); text-align: left;
}
.main-content {
padding: var(--spacing-md);
} }
} }
</style> </style>

@ -0,0 +1,218 @@
<template>
<div class="public-layout">
<!-- 顶部导航栏 -->
<header class="header">
<div class="header-container">
<div class="logo">
<router-link to="/">
<img src="/images/默认头像.jpg" alt="UniLife Logo" class="logo-image" />
<span class="logo-text">UniLife</span>
</router-link>
</div>
<nav class="main-nav">
<router-link to="/" class="nav-item">论坛广场</router-link>
<router-link to="/resources" class="nav-item">学习资源</router-link>
<router-link to="/courses" class="nav-item">课程表</router-link>
</nav>
<div class="user-area">
<template v-if="userStore.isLoggedIn">
<el-dropdown trigger="click">
<div class="user-dropdown-link">
<el-avatar :size="32" :src="userStore.userInfo?.avatar || '/images/默认头像.jpg'" />
<span class="username">{{ userStore.userInfo?.nickname || userStore.userInfo?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/personal/home')">
<el-icon><User /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/account')">
<el-icon><Setting /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/posts')">
<el-icon><Document /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/messages')">
<el-icon><Message /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided @click="logout">
<el-icon><SwitchButton /></el-icon>退
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<router-link to="/login" class="login-btn">
<el-button type="primary" size="small">登录 / 注册</el-button>
</router-link>
</template>
</div>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<router-view />
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-container">
<p>&copy; {{ new Date().getFullYear() }} UniLife - 有你生活优你生活</p>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores';
import { User, Setting, Document, Message, SwitchButton, ArrowDown } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
//
onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUserInfo();
}
});
// 退
const logout = () => {
userStore.logout();
router.push('/');
};
</script>
<style scoped>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
background-color: var(--secondary-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
}
.logo {
display: flex;
align-items: center;
}
.logo a {
display: flex;
align-items: center;
text-decoration: none;
color: var(--primary-color);
}
.logo-image {
height: 32px;
margin-right: 8px;
}
.logo-text {
font-size: 1.5rem;
font-weight: bold;
}
.main-nav {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--text-primary);
font-weight: 500;
padding: 8px 0;
position: relative;
}
.nav-item:hover {
color: var(--primary-color);
}
.nav-item.router-link-active {
color: var(--primary-color);
}
.nav-item.router-link-active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: var(--primary-color);
}
.user-area {
display: flex;
align-items: center;
}
.user-dropdown-link {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.user-dropdown-link:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.username {
margin: 0 8px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-btn {
text-decoration: none;
}
.main-content {
flex: 1;
background-color: var(--bg-color);
padding: 24px 0;
}
.footer {
background-color: var(--secondary-color);
padding: 16px 0;
text-align: center;
color: var(--text-secondary);
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
</style>

@ -5,6 +5,7 @@ import { useUserStore } from '../stores';
// 布局 // 布局
import BaseLayout from '../layouts/BaseLayout.vue'; import BaseLayout from '../layouts/BaseLayout.vue';
import PersonalLayout from '../layouts/PersonalLayout.vue'; import PersonalLayout from '../layouts/PersonalLayout.vue';
import PublicLayout from '../layouts/PublicLayout.vue';
// 页面 // 页面
import Login from '../views/Login.vue'; import Login from '../views/Login.vue';
@ -14,87 +15,122 @@ import NotFound from '../views/NotFound.vue';
// 路由配置 // 路由配置
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
// 公共页面 - 使用PublicLayout布局
{ {
path: '/', path: '/',
component: BaseLayout, component: PublicLayout,
children: [ children: [
// 论坛首页 - 无需登录
{ {
path: '', path: '', // 网站根路径 /
redirect: '/login' name: 'Forum',
component: () => import('../views/forum/PostListView.vue'),
meta: { title: '论坛广场 - UniLife', requiresAuth: false }
},
// 帖子详情 - 无需登录
{
path: 'post/:id', // URL: /post/123
name: 'PostDetail',
component: () => import('../views/forum/PostDetailView.vue'),
props: true,
meta: { title: '帖子详情 - UniLife', requiresAuth: false }
}, },
// 发布帖子 - 需要登录
{
path: 'create-post', // URL: /create-post
name: 'CreatePost',
component: () => import('../views/forum/CreatePostView.vue'),
meta: { title: '发布帖子 - UniLife', requiresAuth: true }
},
// 编辑帖子 - 需要登录
{
path: 'edit-post/:id', // URL: /edit-post/123
name: 'EditPost',
component: () => import('../views/forum/CreatePostView.vue'),
props: true,
meta: { title: '编辑帖子 - UniLife', requiresAuth: true }
},
// 学习资源 - 无需登录
{
path: 'resources', // URL: /resources
name: 'Resources',
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '学习资源 - UniLife', requiresAuth: false }
},
// 课程表 - 无需登录
{
path: 'courses', // URL: /courses
name: 'Courses',
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '课程表 - UniLife', requiresAuth: false }
}
]
},
// 认证相关页面 - 使用BaseLayout布局
{
path: '/',
component: BaseLayout,
children: [
{ {
path: 'login', path: 'login', // URL: /login
name: 'Login', name: 'Login',
component: Login, component: Login,
meta: { meta: { title: '登录/注册 - UniLife', requiresAuth: false }
title: '登录 - UniLife学生论坛',
requiresAuth: false
}
} }
] ]
}, },
// 个人中心页面 - 使用PersonalLayout布局
{ {
path: '/personal', path: '/personal',
component: PersonalLayout, component: PersonalLayout,
meta: { meta: { requiresAuth: true },
requiresAuth: true
},
children: [ children: [
{ {
path: '', path: 'home', // URL: /personal/home
name: 'Home', name: 'PersonalHome',
component: Home, component: Home,
meta: { meta: { title: '个人主页 - UniLife' }
title: '个人主页 - UniLife学生论坛',
requiresAuth: true
}
}, },
{ {
path: 'account', path: 'account', // URL: /personal/account
name: 'AccountManager', name: 'AccountManager',
component: AccountManager, component: AccountManager,
meta: { meta: { title: '账号管理 - UniLife' }
title: '账号管理 - UniLife学生论坛',
requiresAuth: true
}
}, },
// 其他个人中心页面可以在这里添加
{ {
path: 'posts', path: 'posts', // URL: /personal/posts
name: 'Posts', name: 'MyPosts',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面 component: () => import('../views/NotFound.vue'), // 占位符
meta: { meta: { title: '我的帖子 - UniLife' }
title: '我的帖子 - UniLife学生论坛',
requiresAuth: true
}
}, },
{ {
path: 'messages', path: 'messages', // URL: /personal/messages
name: 'Messages', name: 'Messages',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面 component: () => import('../views/NotFound.vue'), // 占位符
meta: { meta: { title: '消息中心 - UniLife' }
title: '消息中心 - UniLife学生论坛',
requiresAuth: true
}
}, },
{ {
path: 'settings', path: 'settings', // URL: /personal/settings
name: 'Settings', name: 'Settings',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面 component: () => import('../views/NotFound.vue'), // 占位符
meta: { meta: { title: '设置 - UniLife' }
title: '设置 - UniLife学生论坛', },
requiresAuth: true // 默认重定向到个人主页
} {
path: '',
redirect: '/personal/home'
} }
] ]
}, },
// Catch-all 404
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'NotFound', name: 'NotFound',
component: NotFound, component: NotFound,
meta: { meta: { title: '页面不存在 - UniLife' }
title: '页面不存在 - UniLife学生论坛'
}
} }
]; ];
@ -105,23 +141,21 @@ const router = createRouter({
// 全局前置守卫 // 全局前置守卫
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title as string || 'UniLife学生论坛'; document.title = to.meta.title as string || 'UniLife学生论坛';
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn;
// 检查是否需要登录权限 // 如果路由需要认证但用户未登录
if (to.matched.some(record => record.meta.requiresAuth)) { if (to.matched.some(record => record.meta.requiresAuth) && !isLoggedIn) {
const userStore = useUserStore(); next({
name: 'Login',
// 如果需要登录但用户未登录,重定向到登录页 query: { redirect: to.fullPath } // 保存原始路径用于登录后重定向
if (!userStore.isLoggedIn) { });
next({ } else if ((to.name === 'Login') && isLoggedIn) {
path: '/login', // 如果用户已登录但尝试访问登录页面,重定向到论坛首页
query: { redirect: to.fullPath } next({ name: 'Forum' });
});
} else {
next();
}
} else { } else {
// 正常导航
next(); next();
} }
}); });

@ -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();
}
}
}
});

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import userApi from '../api/user'; import userApi from '../api/user';
import type { UserInfo } from '../api/user'; import type { UserInfo, UpdatePasswordParams } from '../api/user';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
@ -123,19 +123,31 @@ export const useUserStore = defineStore('user', () => {
username?: string; username?: string;
bio?: string; bio?: string;
gender?: number; gender?: number;
birthday?: string; department?: string;
major?: string;
grade?: string;
}) => { }) => {
try { try {
loading.value = true; loading.value = true;
const res = await userApi.updateProfile(data); const params: any = {};
if (data.username !== undefined) params.username = data.username;
if (data.bio !== undefined) params.bio = data.bio;
if (data.gender !== undefined) params.gender = data.gender;
if (data.department !== undefined) params.department = data.department;
if (data.major !== undefined) params.major = data.major;
if (data.grade !== undefined) params.grade = data.grade;
const res = await userApi.updateProfile(params);
if (res.code === 200) { if (res.code === 200) {
// 更新本地用户信息 // 更新本地用户信息
if (userInfo.value) { if (userInfo.value) {
userInfo.value = { if (data.username !== undefined) userInfo.value.username = data.username;
...userInfo.value, if (data.bio !== undefined) userInfo.value.bio = data.bio;
...data if (data.gender !== undefined) userInfo.value.gender = data.gender;
}; if (data.department !== undefined) userInfo.value.department = data.department;
if (data.major !== undefined) userInfo.value.major = data.major;
if (data.grade !== undefined) userInfo.value.grade = data.grade;
} }
ElMessage.success('个人资料更新成功'); ElMessage.success('个人资料更新成功');
@ -151,11 +163,18 @@ export const useUserStore = defineStore('user', () => {
} }
}; };
// 更新密码 // 更新用户密码
const updatePassword = async (code: string, newPassword: string) => { const updatePassword = async (data: {
newPassword?: string;
code?: string;
}) => {
try { try {
loading.value = true; loading.value = true;
const res = await userApi.updatePassword({ code, newPassword }); const params: UpdatePasswordParams = {};
if (data.newPassword) params.newPassword = data.newPassword;
if (data.code) params.code = data.code;
const res = await userApi.updatePassword(params);
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('密码修改成功'); ElMessage.success('密码修改成功');

@ -1,10 +1,180 @@
<template>
<div class="account-manager-container">
<el-row :gutter="24">
<el-col :xs="24" :sm="24" :md="10" :lg="10" :xl="10">
<el-card class="profile-section" shadow="hover">
<template #header>
<div class="card-header">
<span>个人资料</span>
<el-button
type="primary"
link
@click="toggleProfileEdit"
>
{{ isProfileEditable ? '取消' : '编辑' }}
</el-button>
</div>
</template>
<div class="avatar-section">
<div
class="avatar-container"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
@click="openAvatarDialog"
>
<img :src="avatarUrl" alt="User Avatar" class="avatar-image">
<div v-if="isAvatarHovered" class="avatar-overlay">
<el-icon><Camera /></el-icon>
<span>修改头像</span>
</div>
</div>
</div>
<el-form
:model="profileForm"
ref="profileFormRef"
label-width="80px"
:disabled="!isProfileEditable"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="profileForm.username" :disabled="!isProfileEditable" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="profileForm.gender">
<el-radio v-for="option in genderOptions" :key="option.value" :value="option.value">
{{ option.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="个人简介" prop="bio">
<el-input v-model="profileForm.bio" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="学院" prop="department">
<el-input v-model="profileForm.department" />
</el-form-item>
<el-form-item label="专业" prop="major">
<el-input v-model="profileForm.major" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="profileForm.grade" />
</el-form-item>
</el-form>
<div class="profile-actions" v-if="isProfileEditable">
<el-button type="primary" @click="submitProfile"></el-button>
<el-button @click="toggleProfileEdit"></el-button>
</div>
<div v-else>
<el-button type="primary" @click="toggleProfileEdit"></el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="14" :lg="14" :xl="14">
<el-card class="account-info-section" shadow="hover" style="margin-bottom: 20px;">
<template #header>
<div class="card-header">
<span>账号信息</span>
</div>
</template>
<div v-if="userStore.userInfo" class="account-info-details">
<p><strong>用户ID:</strong> {{ userStore.userInfo.id }}</p>
<p><strong>当前用户名:</strong> {{ userStore.userInfo.username }}</p>
<p><strong>邮箱:</strong> {{ userStore.userInfo.email }}</p>
<p><strong>学号:</strong> {{ userStore.userInfo.studentId || '未设置' }}</p>
<p><strong>学院:</strong> {{ userStore.userInfo.department || '未设置' }}</p>
<p><strong>专业:</strong> {{ userStore.userInfo.major || '未设置' }}</p>
<p><strong>年级:</strong> {{ userStore.userInfo.grade || '未设置' }}</p>
<p><strong>积分:</strong> {{ userStore.userInfo.points ?? 'N/A' }}</p>
<p><strong>角色:</strong> {{ userStore.userInfo.role === 1 ? '管理员' : '普通用户' }}</p>
<p><strong>邮箱已验证:</strong> {{ userStore.userInfo.isVerified === 1 ? '是' : '否' }}</p>
</div>
</el-card>
<el-card class="password-section" shadow="hover">
<template #header>
<div class="card-header">
<span>密码修改</span>
<el-button
type="primary"
link
@click="togglePasswordEdit"
>
{{ isPasswordEditable ? '取消' : '修改密码' }}
</el-button>
</div>
</template>
<el-form :model="passwordForm" label-width="80px" :disabled="!isPasswordEditable">
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" />
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="passwordForm.code" />
<el-button
type="primary"
@click="handleSendCode"
:disabled="countdown > 0"
>
{{ codeButtonText }}
</el-button>
</el-form-item>
</el-form>
<div class="password-actions" v-if="isPasswordEditable">
<el-button type="primary" @click="submitPassword"></el-button>
<el-button @click="togglePasswordEdit"></el-button>
</div>
</el-card>
</el-col>
</el-row>
<!-- 头像上传对话框 -->
<el-dialog
v-model="isAvatarDialogVisible"
title="更换头像"
width="400px"
>
<div class="avatar-upload-container">
<div class="avatar-preview-container">
<img
v-if="avatarPreviewUrl"
:src="avatarPreviewUrl"
class="avatar-preview"
>
<div v-else class="avatar-placeholder">
<el-icon><Plus /></el-icon>
<span>选择图片</span>
</div>
</div>
<input
type="file"
accept="image/*"
@change="handleAvatarChange"
class="avatar-input"
id="avatar-input"
>
<label for="avatar-input" class="btn btn-primary">选择图片</label>
</div>
<template #footer>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="isAvatarDialogVisible = false">取消</button>
<button class="btn btn-primary" @click="uploadAvatar" :disabled="!avatarPreviewUrl">上传</button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { useForm } from 'vee-validate'; import { useForm } from 'vee-validate';
import * as yup from 'yup';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
import { useEmailCode } from '../hooks/useEmailCode'; import { useEmailCode } from '../hooks/useEmailCode';
import { Camera, Plus } from '@element-plus/icons-vue';
const userStore = useUserStore(); const userStore = useUserStore();
const { sendEmailCode, countdown } = useEmailCode(); const { sendEmailCode, countdown } = useEmailCode();
@ -18,14 +188,16 @@ const profileForm = reactive({
username: '', username: '',
gender: 0, gender: 0,
bio: '', bio: '',
birthday: '' department: '',
major: '',
grade: '',
}); });
// //
const passwordForm = reactive({ const passwordForm = reactive({
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
code: '' code: '',
}); });
// //
@ -43,26 +215,11 @@ const genderOptions = [
]; ];
// //
const profileValidationSchema = yup.object({ const { handleSubmit: handleProfileSubmit, setValues: setProfileValues } = useForm({
username: yup.string().required('用户名不能为空'),
bio: yup.string()
});
const passwordValidationSchema = yup.object({
newPassword: yup.string().required('新密码不能为空').min(6, '密码至少6位'),
confirmPassword: yup.string()
.required('确认密码不能为空')
.oneOf([yup.ref('newPassword')], '两次密码不一致'),
code: yup.string().required('验证码不能为空')
});
const { handleSubmit: handleProfileSubmit } = useForm({
validationSchema: profileValidationSchema,
initialValues: profileForm initialValues: profileForm
}); });
const { handleSubmit: handlePasswordSubmit } = useForm({ const { handleSubmit: handlePasswordSubmit } = useForm({
validationSchema: passwordValidationSchema,
initialValues: passwordForm initialValues: passwordForm
}); });
@ -74,7 +231,9 @@ const fetchUserInfo = async () => {
profileForm.username = userInfo.username || ''; profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0; profileForm.gender = userInfo.gender || 0;
profileForm.bio = userInfo.bio || ''; profileForm.bio = userInfo.bio || '';
profileForm.birthday = userInfo.birthday || ''; profileForm.department = userInfo.department || '';
profileForm.major = userInfo.major || '';
profileForm.grade = userInfo.grade || '';
avatarUrl.value = userInfo.avatar || '/images/默认头像.jpg'; avatarUrl.value = userInfo.avatar || '/images/默认头像.jpg';
} }
}; };
@ -88,7 +247,9 @@ const toggleProfileEdit = () => {
profileForm.username = userInfo.username || ''; profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0; profileForm.gender = userInfo.gender || 0;
profileForm.bio = userInfo.bio || ''; profileForm.bio = userInfo.bio || '';
profileForm.birthday = userInfo.birthday || ''; profileForm.department = userInfo.department || '';
profileForm.major = userInfo.major || '';
profileForm.grade = userInfo.grade || '';
} }
} }
@ -109,30 +270,65 @@ const togglePasswordEdit = () => {
// //
const submitProfile = handleProfileSubmit(async (values) => { const submitProfile = handleProfileSubmit(async (values) => {
const success = await userStore.updateProfile({ console.log('Submitting profile with values:', values);
username: values.username, try {
bio: values.bio, const dataToSubmit = {
gender: values.gender, username: profileForm.username,
birthday: values.birthday bio: profileForm.bio,
}); gender: profileForm.gender,
department: profileForm.department,
if (success) { major: profileForm.major,
isProfileEditable.value = false; grade: profileForm.grade,
};
const success = await userStore.updateProfile(dataToSubmit);
if (success) {
isProfileEditable.value = false;
// ElMessage.success(''); // Success message is now handled in the store
} else {
// General error message is handled in the store, but we might want specific handling here if needed
// ElMessage.error('');
}
} catch (error: any) {
if (error.response && error.response.status === 409) {
ElMessage.error(error.response.data.message || '用户名已被占用,请选择其他用户名');
} else if (error.response && error.response.data && error.response.data.message) {
ElMessage.error('更新失败: ' + error.response.data.message);
} else {
ElMessage.error('个人资料更新失败,请稍后再试');
}
console.error('Error updating profile:', error);
} }
}); });
// //
const submitPassword = handlePasswordSubmit(async (values) => { const submitPassword = handlePasswordSubmit(async (values) => {
const success = await userStore.updatePassword( console.log('Submitting password change with passwordForm state:', passwordForm);
values.code, try {
values.newPassword const dataToSubmit = {
); newPassword: passwordForm.newPassword,
code: passwordForm.code
};
if (success) { const success = await userStore.updatePassword(dataToSubmit);
isPasswordEditable.value = false;
passwordForm.newPassword = ''; if (success) {
passwordForm.confirmPassword = ''; isPasswordEditable.value = false;
passwordForm.code = ''; passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
passwordForm.code = '';
// ElMessage.success(''); // Handled in store
} else {
// ElMessage.error(''); // General error, store might provide specifics
}
} catch (error: any) {
if (error.response && error.response.data && error.response.data.message) {
ElMessage.error('更新失败: ' + error.response.data.message);
} else {
ElMessage.error('密码更新失败,请稍后再试');
}
console.error('Error updating password:', error);
} }
}); });
@ -204,292 +400,28 @@ onMounted(() => {
}); });
</script> </script>
<template>
<div class="account-manager">
<div class="page-header">
<h1>账号管理</h1>
<p>管理你的个人资料和账号信息</p>
</div>
<div class="account-container">
<!-- 左侧个人资料 -->
<div class="card profile-card">
<div class="card-header">
<h2>个人资料</h2>
<button
class="btn"
:class="isProfileEditable ? 'btn-secondary' : 'btn-primary'"
@click="toggleProfileEdit"
>
{{ isProfileEditable ? '取消' : '编辑' }}
</button>
</div>
<div class="avatar-container">
<div
class="avatar"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
@click="openAvatarDialog"
>
<img :src="avatarUrl" alt="用户头像">
<div class="avatar-overlay" v-if="isAvatarHovered">
<el-icon><upload-filled /></el-icon>
<span>更换头像</span>
</div>
</div>
</div>
<form @submit.prevent="submitProfile">
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
type="text"
v-model="profileForm.username"
:readonly="!isProfileEditable"
class="form-input"
>
</div>
<div class="form-group">
<label>性别</label>
<div class="radio-group">
<label v-for="option in genderOptions" :key="option.value" class="radio-label">
<input
type="radio"
:value="option.value"
v-model="profileForm.gender"
:disabled="!isProfileEditable"
>
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div class="form-group">
<label for="birthday">生日</label>
<input
id="birthday"
type="date"
v-model="profileForm.birthday"
:readonly="!isProfileEditable"
class="form-input"
>
</div>
<div class="form-group">
<label for="bio">个人简介</label>
<textarea
id="bio"
v-model="profileForm.bio"
:readonly="!isProfileEditable"
class="form-input textarea"
rows="4"
></textarea>
</div>
<div class="form-actions" v-if="isProfileEditable">
<button type="submit" class="btn btn-primary">保存修改</button>
</div>
</form>
</div>
<!-- 右侧账号信息 -->
<div class="card account-card">
<div class="card-header">
<h2>账号信息</h2>
</div>
<div class="form-group">
<label>邮箱</label>
<input
type="email"
:value="userStore.userInfo?.email || ''"
readonly
class="form-input"
>
<p class="form-hint">邮箱地址不可修改</p>
</div>
<div class="card-header password-header">
<h3>密码设置</h3>
<button
class="btn"
:class="isPasswordEditable ? 'btn-secondary' : 'btn-primary'"
@click="togglePasswordEdit"
>
{{ isPasswordEditable ? '取消' : '修改密码' }}
</button>
</div>
<form v-if="isPasswordEditable" @submit.prevent="submitPassword">
<div class="form-group">
<label for="newPassword">新密码</label>
<input
id="newPassword"
type="password"
v-model="passwordForm.newPassword"
class="form-input"
placeholder="请输入新密码"
>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input
id="confirmPassword"
type="password"
v-model="passwordForm.confirmPassword"
class="form-input"
placeholder="请再次输入新密码"
>
</div>
<div class="form-group">
<label for="code">验证码</label>
<div class="code-input-group">
<input
id="code"
type="text"
v-model="passwordForm.code"
class="form-input"
placeholder="请输入验证码"
>
<button
type="button"
class="code-btn"
@click="handleSendCode"
:disabled="countdown > 0"
>
{{ codeButtonText }}
</button>
</div>
<p class="form-hint">验证码将发送到您的邮箱</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">确认修改</button>
</div>
</form>
<div class="account-security">
<h3>账号安全</h3>
<p>最后登录时间: 2023-06-15 14:30:25</p>
<p>最后登录IP: 192.168.1.1</p>
</div>
</div>
</div>
<!-- 头像上传对话框 -->
<el-dialog
v-model="isAvatarDialogVisible"
title="更换头像"
width="400px"
>
<div class="avatar-upload-container">
<div class="avatar-preview-container">
<img
v-if="avatarPreviewUrl"
:src="avatarPreviewUrl"
class="avatar-preview"
>
<div v-else class="avatar-placeholder">
<el-icon><plus /></el-icon>
<span>选择图片</span>
</div>
</div>
<input
type="file"
accept="image/*"
@change="handleAvatarChange"
class="avatar-input"
id="avatar-input"
>
<label for="avatar-input" class="btn btn-primary">选择图片</label>
</div>
<template #footer>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="isAvatarDialogVisible = false">取消</button>
<button class="btn btn-primary" @click="uploadAvatar" :disabled="!avatarPreviewUrl">上传</button>
</div>
</template>
</el-dialog>
<!-- 装饰元素 -->
<div class="decoration-element star-1"></div>
<div class="decoration-element star-2"></div>
<div class="decoration-element heart">💜</div>
<div class="decoration-element cat">🐱</div>
</div>
</template>
<style scoped> <style scoped>
.account-manager { .account-manager-container {
max-width: 1000px; padding: 20px;
margin: 0 auto; max-width: 1200px;
position: relative; margin: auto;
}
.page-header {
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-xxl);
color: var(--primary-color);
margin-bottom: var(--spacing-xs);
}
.page-header p {
color: var(--text-secondary);
}
.account-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
}
.card {
background-color: var(--bg-primary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow-md);
} }
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--spacing-lg); font-size: 1.1rem;
} font-weight: bold;
.card-header h2 {
font-size: var(--font-size-xl);
color: var(--primary-color);
margin: 0;
} }
.card-header h3 { .avatar-section {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0;
}
.password-header {
margin-top: var(--spacing-xl);
}
.avatar-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: var(--spacing-xl); margin-bottom: 20px;
} }
.avatar { .avatar-container {
width: 120px; width: 120px;
height: 120px; height: 120px;
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
@ -499,7 +431,7 @@ onMounted(() => {
border: 3px solid var(--primary-light); border: 3px solid var(--primary-light);
} }
.avatar img { .avatar-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@ -524,210 +456,38 @@ onMounted(() => {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.form-group { .profile-actions,
margin-bottom: var(--spacing-lg); .password-actions {
} margin-top: 20px;
text-align: right;
.form-group label {
display: block;
margin-bottom: var(--spacing-sm);
color: var(--text-secondary);
font-weight: 500;
}
.form-input {
width: 100%;
padding: var(--spacing-md);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-md);
font-size: var(--font-size-md);
transition: border-color var(--transition-normal);
}
.form-input:focus {
border-color: var(--primary-light);
outline: none;
}
.form-input:read-only {
background-color: var(--secondary-color);
cursor: not-allowed;
}
.textarea {
resize: vertical;
min-height: 100px;
}
.radio-group {
display: flex;
gap: var(--spacing-lg);
}
.radio-label {
display: flex;
align-items: center;
cursor: pointer;
}
.radio-label input {
margin-right: var(--spacing-xs);
}
.form-hint {
font-size: var(--font-size-sm);
color: var(--text-light);
margin-top: var(--spacing-xs);
}
.code-input-group {
display: flex;
gap: var(--spacing-md);
}
.code-input-group .form-input {
flex: 1;
}
.code-btn {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: background-color var(--transition-normal);
white-space: nowrap;
}
.code-btn:hover:not(:disabled) {
background-color: var(--primary-dark);
}
.code-btn:disabled {
background-color: var(--text-light);
cursor: not-allowed;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-lg);
}
.account-security {
margin-top: var(--spacing-xl);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
.account-security h3 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.account-security p {
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.avatar-upload-container {
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-preview-container {
width: 200px;
height: 200px;
border-radius: var(--border-radius-md);
overflow: hidden;
margin-bottom: var(--spacing-lg);
border: 2px dashed var(--border-color);
display: flex;
justify-content: center;
align-items: center;
}
.avatar-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-light);
}
.avatar-placeholder .el-icon {
font-size: 40px;
margin-bottom: var(--spacing-sm);
}
.avatar-input {
display: none;
} }
.dialog-footer { .account-info-details p {
display: flex; margin-bottom: 10px;
justify-content: flex-end; font-size: 0.95rem;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
/* 装饰元素 */
.decoration-element {
position: absolute;
z-index: -1;
font-size: 24px;
animation: float 5s ease-in-out infinite;
}
.star-1 {
top: 50px;
right: 50px;
animation-delay: 0s;
}
.star-2 {
bottom: 100px;
left: 50px;
animation-delay: 1s;
} }
.heart { .account-info-details strong {
top: 200px; margin-right: 8px;
left: 100px;
animation-delay: 2s;
} }
.cat { /* Responsive adjustments */
bottom: 50px; @media (max-width: 768px) {
right: 100px; .el-col {
animation-delay: 3s; margin-bottom: 20px;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
} }
50% { .profile-actions,
transform: translateY(-10px); .password-actions {
text-align: center;
} }
} .el-form-item {
margin-bottom: 15px;
/* 响应式设计 */
@media (max-width: 768px) {
.account-container {
grid-template-columns: 1fr;
} }
.el-card {
.decoration-element { margin-bottom: 20px;
display: none; }
.el-col:last-child .el-card:last-child {
margin-bottom: 0; /* Remove margin from the very last card on mobile */
} }
} }
</style> </style>

@ -49,7 +49,9 @@ const handlePasswordLogin = async () => {
if (success) { if (success) {
// store true // store true
ElMessage.success('登录成功'); // ElMessage.success('登录成功'); //
router.push('/personal'); // URLURL
const redirectUrl = router.currentRoute.value.query.redirect as string || '/';
router.push(redirectUrl);
} else { } else {
} }
} catch (error: any) { } catch (error: any) {
@ -67,7 +69,9 @@ const handleCodeLogin = async () => {
if (success) { if (success) {
ElMessage.success('登录成功'); // ElMessage.success('登录成功'); //
router.push('/personal'); // URLURL
const redirectUrl = router.currentRoute.value.query.redirect as string || '/';
router.push(redirectUrl);
} else { } else {
// store // store
// ElMessage.error(''); // ElMessage.error('');
@ -90,7 +94,8 @@ const handleRegister = async () => {
if (success) { if (success) {
ElMessage.success('注册成功,已自动登录'); // ElMessage.success('注册成功,已自动登录'); //
router.push('/personal'); //
router.push('/');
} else { } else {
} }
} catch (error: any) { } catch (error: any) {

@ -0,0 +1,278 @@
<template>
<div class="create-post-view">
<div class="page-header">
<h1 class="page-title">{{ isEditing ? '编辑帖子' : '发布新帖子' }}</h1>
<el-button @click="goBack" class="back-button" :icon="ArrowLeft">返回</el-button>
</div>
<el-card class="post-form-card">
<el-form
ref="postFormRef"
:model="postForm"
:rules="postRules"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="postForm.title"
placeholder="请输入帖子标题5-50字"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select
v-model="postForm.categoryId"
placeholder="请选择帖子分类"
style="width: 100%"
:loading="loadingCategories"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="postForm.content"
type="textarea"
placeholder="请输入帖子内容10-5000字"
:rows="12"
maxlength="5000"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="submitting"
style="width: 120px"
>
{{ isEditing ? '保存更改' : '发布帖子' }}
</el-button>
<el-button @click="goBack" style="margin-left: 16px">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage, ElForm } from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import postApi from '@/api/post';
import type { CategoryItem, CreatePostParams } from '@/api/post';
import { useUserStore } from '@/stores';
//
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
//
const isEditing = computed(() => !!route.params.id);
const postId = computed(() => route.params.id ? Number(route.params.id) : null);
//
const postFormRef = ref<InstanceType<typeof ElForm> | null>(null);
const postForm = reactive<CreatePostParams>({
title: '',
content: '',
categoryId: null as unknown as number // null
});
const postRules = {
title: [
{ required: true, message: '请输入帖子标题', trigger: 'blur' },
{ min: 5, max: 50, message: '标题长度应在5到50个字符之间', trigger: 'blur' }
],
categoryId: [
{ required: true, message: '请选择帖子分类', trigger: 'change' },
{ type: 'number', min: 1, message: '请选择有效的分类', trigger: 'change' }
],
content: [
{ required: true, message: '请输入帖子内容', trigger: 'blur' },
{ min: 10, max: 5000, message: '内容长度应在10到5000个字符之间', trigger: 'blur' }
]
};
//
const submitting = ref(false);
//
const categories = ref<CategoryItem[]>([]);
const loadingCategories = ref(false);
//
const fetchCategories = async () => {
loadingCategories.value = true;
try {
const res = await postApi.getCategories();
if (res.code === 200 && res.data.list) {
categories.value = res.data.list;
} else {
ElMessage.error('获取分类失败');
}
} catch (error) {
console.error('获取分类出错:', error);
ElMessage.error('网络错误,请稍后重试');
} finally {
loadingCategories.value = false;
}
};
//
const fetchPostDetail = async () => {
if (!isEditing.value || !postId.value) return;
try {
const res = await postApi.getPostDetail(postId.value);
if (res.code === 200 && res.data) {
//
postForm.title = res.data.title;
postForm.content = res.data.content;
postForm.categoryId = res.data.categoryId;
} else {
ElMessage.error('获取帖子详情失败');
//
router.push('/');
}
} catch (error) {
console.error('获取帖子详情出错:', error);
ElMessage.error('网络错误,请稍后重试');
router.push('/');
}
};
//
const goBack = () => {
router.back();
};
//
const handleSubmit = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: route.fullPath }
});
return;
}
//
if (!postForm.categoryId || postForm.categoryId <= 0) {
ElMessage.warning('请选择有效的帖子分类');
return;
}
await postFormRef.value?.validate(async (valid, fields) => {
if (!valid) {
console.log('表单验证失败:', fields);
return;
}
submitting.value = true;
try {
let res;
if (isEditing.value && postId.value) {
//
res = await postApi.updatePost(postId.value, postForm);
if (res.code === 200) {
ElMessage.success('帖子更新成功');
router.push(`/post/${postId.value}`);
} else {
ElMessage.error(res.message || '帖子更新失败');
}
} else {
//
res = await postApi.createPost(postForm);
if (res.code === 200 && res.data.postId) {
ElMessage.success('帖子发布成功');
router.push(`/post/${res.data.postId}`);
} else {
ElMessage.error(res.message || '帖子发布失败');
}
}
} catch (error: any) {
console.error('提交帖子出错:', error);
ElMessage.error(error.message || '网络错误,请稍后重试');
} finally {
submitting.value = false;
}
});
};
//
onMounted(async () => {
//
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: route.fullPath }
});
return;
}
//
await fetchCategories();
//
if (isEditing.value) {
await fetchPostDetail();
}
});
</script>
<style scoped>
.create-post-view {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 1.5rem;
margin: 0;
color: var(--el-text-color-primary);
}
.post-form-card {
margin-bottom: 40px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.create-post-view {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.back-button {
align-self: flex-start;
}
}
</style>

@ -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) => {
// storeselectCategory
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 { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}
}
}) })

@ -42,7 +42,7 @@ public class PostController {
@Operation(summary = "获取帖子列表") @Operation(summary = "获取帖子列表")
@GetMapping @GetMapping
public Result<?> getPostList( public Result<?> getPostList(
@RequestParam(value = "category", required = false) Long categoryId, @RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "sort", defaultValue = "latest") String sort) { @RequestParam(value = "sort", defaultValue = "latest") String sort) {

@ -10,6 +10,7 @@ import java.util.Date;
public interface UserMapper { public interface UserMapper {
void insert(User user); void insert(User user);
User findByEmail(String email); User findByEmail(String email);
User findByUsername(String username);
void updateLoginInfo(@Param("userId") Long userId, void updateLoginInfo(@Param("userId") Long userId,
@Param("ipLocation") String ipLocation, @Param("ipLocation") String ipLocation,
@Param("loginTime") Date loginTime); @Param("loginTime") Date loginTime);

@ -11,7 +11,8 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class UpdateProfileDTO { public class UpdateProfileDTO {
private String nickname; private String username; // Added back to allow username updates
// private String nickname; // Removed as per user request and frontend changes
private String bio; private String bio;
private Byte gender; private Byte gender;
private String department; private String department;

@ -11,6 +11,7 @@ import com.unilife.mapper.PostMapper;
import com.unilife.mapper.UserMapper; import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.CreatePostDTO; import com.unilife.model.dto.CreatePostDTO;
import com.unilife.model.dto.UpdatePostDTO; import com.unilife.model.dto.UpdatePostDTO;
import com.unilife.model.entity.Category;
import com.unilife.model.entity.Post; import com.unilife.model.entity.Post;
import com.unilife.model.entity.User; import com.unilife.model.entity.User;
import com.unilife.model.vo.PostListVO; import com.unilife.model.vo.PostListVO;
@ -91,8 +92,15 @@ public class PostServiceImpl implements PostService {
User user = userMapper.getUserById(post.getUserId()); User user = userMapper.getUserById(post.getUserId());
// 获取分类信息 // 获取分类信息
// 注意这里假设已经创建了CategoryMapper接口实际开发中需要先创建 Category category = categoryMapper.getById(post.getCategoryId());
// Category category = categoryMapper.getById(post.getCategoryId()); String categoryName = category != null ? category.getName() : "未知分类";
// 查询用户是否点赞过该帖子
boolean isLiked = false;
if (userId != null) {
Boolean liked = postLikeMapper.isLiked(postId, userId);
isLiked = liked != null && liked;
}
// 构建返回数据 // 构建返回数据
PostVO postVO = PostVO.builder() PostVO postVO = PostVO.builder()
@ -103,11 +111,11 @@ public class PostServiceImpl implements PostService {
.nickname(user != null ? user.getNickname() : "未知用户") .nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null) .avatar(user != null ? user.getAvatar() : null)
.categoryId(post.getCategoryId()) .categoryId(post.getCategoryId())
.categoryName("未知分类") // 实际开发中应该从category对象获取 .categoryName(categoryName) // 使用从数据库查询到的真实分类名称
.viewCount(post.getViewCount() + 1) // 已经增加了浏览次数 .viewCount(post.getViewCount() + 1) // 已经增加了浏览次数
.likeCount(post.getLikeCount()) .likeCount(post.getLikeCount())
.commentCount(post.getCommentCount()) .commentCount(post.getCommentCount())
.isLiked(false) // 实际开发中应该查询用户是否点赞 .isLiked(isLiked) // 设置已查询的点赞状态
.createdAt(post.getCreatedAt()) .createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt()) .updatedAt(post.getUpdatedAt())
.build(); .build();
@ -131,6 +139,21 @@ public class PostServiceImpl implements PostService {
// 获取分页信息 // 获取分页信息
PageInfo<Post> pageInfo = new PageInfo<>(posts); PageInfo<Post> pageInfo = new PageInfo<>(posts);
// 收集所有帖子的分类 ID
List<Long> categoryIds = posts.stream()
.map(Post::getCategoryId)
.distinct()
.collect(Collectors.toList());
// 批量获取分类信息
Map<Long, String> categoryMap = new HashMap<>();
if (!categoryIds.isEmpty()) {
categoryIds.forEach(id -> {
Category category = categoryMapper.getById(id);
categoryMap.put(id, category != null ? category.getName() : "未知分类");
});
}
// 转换为VO // 转换为VO
List<PostListVO> postListVOs = posts.stream().map(post -> { List<PostListVO> postListVOs = posts.stream().map(post -> {
User user = userMapper.getUserById(post.getUserId()); User user = userMapper.getUserById(post.getUserId());
@ -142,7 +165,7 @@ public class PostServiceImpl implements PostService {
.nickname(user != null ? user.getNickname() : "未知用户") .nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null) .avatar(user != null ? user.getAvatar() : null)
.categoryId(post.getCategoryId()) .categoryId(post.getCategoryId())
.categoryName("未知分类") .categoryName(categoryMap.getOrDefault(post.getCategoryId(), "未知分类"))
.viewCount(post.getViewCount()) .viewCount(post.getViewCount())
.likeCount(post.getLikeCount()) .likeCount(post.getLikeCount())
.commentCount(post.getCommentCount()) .commentCount(post.getCommentCount())

@ -33,8 +33,6 @@ import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date; import java.util.Date;
@ -339,24 +337,49 @@ public class UserServiceImpl implements UserService {
@Override @Override
public Result updateUserProfile(Long userId, UpdateProfileDTO profileDTO) { public Result updateUserProfile(Long userId, UpdateProfileDTO profileDTO) {
// 检查用户是否存在 User currentUser = userMapper.getUserById(userId); // Changed from findById to getUserById based on UserMapper.java
User user = userMapper.getUserById(userId); if (currentUser == null) {
if (user == null) {
return Result.error(404, "用户不存在"); return Result.error(404, "用户不存在");
} }
// 更新用户信息 // 检查用户名是否更改以及是否重复
user.setNickname(profileDTO.getNickname()); if (StringUtils.isNotEmpty(profileDTO.getUsername()) && !profileDTO.getUsername().equals(currentUser.getUsername())) {
user.setBio(profileDTO.getBio()); User existingUserWithNewUsername = userMapper.findByUsername(profileDTO.getUsername());
user.setGender(profileDTO.getGender()); if (existingUserWithNewUsername != null) {
user.setDepartment(profileDTO.getDepartment()); return Result.error(409, "用户名已被占用,请选择其他用户名"); // 409 Conflict
user.setMajor(profileDTO.getMajor()); }
user.setGrade(profileDTO.getGrade()); currentUser.setUsername(profileDTO.getUsername());
}
// 保存更新 // 更新用户信息
userMapper.updateUserProfile(user); // 注意这里应该只更新profileDTO中存在的字段且要考虑空值情况
// if (StringUtils.isNotEmpty(profileDTO.getNickname())) { // Commented out as nickname is removed from DTO
// user.setNickname(profileDTO.getNickname());
// }
if (StringUtils.isNotEmpty(profileDTO.getBio())) {
currentUser.setBio(profileDTO.getBio()); // Changed user to currentUser
}
if (profileDTO.getGender() != null) {
currentUser.setGender(profileDTO.getGender()); // Changed user to currentUser
}
if (StringUtils.isNotEmpty(profileDTO.getDepartment())) {
currentUser.setDepartment(profileDTO.getDepartment()); // Changed user to currentUser
}
if (StringUtils.isNotEmpty(profileDTO.getMajor())) {
currentUser.setMajor(profileDTO.getMajor()); // Changed user to currentUser
}
if (StringUtils.isNotEmpty(profileDTO.getGrade())) {
currentUser.setGrade(profileDTO.getGrade()); // Changed user to currentUser
}
return Result.success(null, "个人资料更新成功"); try {
userMapper.updateUserProfile(currentUser); // Call void method
log.info("用户 {} 的个人资料更新成功", userId);
return Result.success("个人资料更新成功");
} catch (Exception e) {
log.error("用户 {} 的个人资料更新时发生数据库错误: {}", userId, e.getMessage());
return Result.error(500, "个人资料更新失败,服务器内部错误");
}
} }
@Override @Override

@ -32,8 +32,9 @@
<select id="getListByCategory" resultType="com.unilife.model.entity.Post"> <select id="getListByCategory" resultType="com.unilife.model.entity.Post">
SELECT * FROM posts SELECT * FROM posts
<where> <where>
status != 0 <!-- 始终只获取未删除的帖子 -->
<if test="categoryId != null"> <if test="categoryId != null">
category_id = #{categoryId} AND category_id = #{categoryId}
</if> </if>
</where> </where>
<choose> <choose>

@ -43,6 +43,12 @@
WHERE email = #{email} WHERE email = #{email}
</select> </select>
<select id="findByUsername" resultMap="userResultMap">
SELECT id, email, password, username, nickname, avatar, role, is_verified, status, login_ip
FROM users
WHERE username = #{username}
</select>
<update id="updateLoginInfo"> <update id="updateLoginInfo">
UPDATE users UPDATE users
SET login_ip = #{ipLocation}, SET login_ip = #{ipLocation},
@ -107,13 +113,13 @@
<update id="updateUserProfile" parameterType="com.unilife.model.entity.User"> <update id="updateUserProfile" parameterType="com.unilife.model.entity.User">
UPDATE users UPDATE users
SET nickname = #{nickname}, SET username = #{username},
nickname = #{nickname},
bio = #{bio}, bio = #{bio},
gender = #{gender}, gender = #{gender},
department = #{department}, department = #{department},
major = #{major}, major = #{major},
grade = #{grade}, grade = #{grade}
updated_at = NOW()
WHERE id = #{id} WHERE id = #{id}
</update> </update>

Loading…
Cancel
Save