2991692032 2 days ago
parent f628c6dece
commit 95b833a711

@ -4,7 +4,8 @@ import { get, post as httpPost, put, del } from './request';
export interface PostItem {
id: number;
title: string;
content: string;
summary?: string; // Added: for post list item summary
content: string; // Existing: for post detail content
userId: number;
nickname: string;
avatar: string;
@ -15,6 +16,13 @@ export interface PostItem {
commentCount: number;
createdAt: 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 {
@ -26,8 +34,8 @@ export interface CreatePostParams {
// API方法
export default {
// 获取帖子列表
getPosts(params: { page?: number; size?: number; category?: number; sort?: string }) {
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', params);
getPosts(params: { pageNum?: number; pageSize?: number; categoryId?: number; sort?: string }) {
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) {
const params = userId ? { userId } : {};
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 {
id: number;
username: string;
email: string;
avatar?: string;
gender?: number;
bio?: string;
gender?: number;
birthday?: string;
studentId?: string;
department?: string;
major?: string;
grade?: string;
points?: number;
role?: number;
isVerified?: number;
}
export interface LoginParams {
@ -23,7 +27,6 @@ export interface RegisterParams {
email: string;
password: string;
username?: string;
nickname?: string;
studentId?: string;
department?: string;
major?: string;
@ -44,7 +47,9 @@ export interface UpdateProfileParams {
username?: string;
bio?: string;
gender?: number;
birthday?: string;
department?: string;
major?: string;
grade?: string;
}
export interface UpdatePasswordParams {

@ -2,18 +2,19 @@
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores';
import { HomeFilled, User, Document, Message, Setting, ArrowLeft } from '@element-plus/icons-vue';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
//
// -
const menuItems = [
{ name: 'Home', title: '个人主页', icon: 'home' },
{ name: 'AccountManager', title: '账号管理', icon: 'user' },
{ name: 'Posts', title: '我的帖子', icon: 'document' },
{ name: 'Messages', title: '消息中心', icon: 'message' },
{ name: 'Settings', title: '设置', icon: 'setting' }
{ name: 'PersonalHome', title: '个人主页', icon: HomeFilled, path: '/personal/home' },
{ name: 'AccountManager', title: '账号管理', icon: User, path: '/personal/account' },
{ name: 'MyPosts', title: '我的帖子', icon: Document, path: '/personal/posts' },
{ name: 'Messages', title: '消息中心', icon: Message, path: '/personal/messages' },
{ name: 'Settings', title: '设置', icon: Setting, path: '/personal/settings' }
];
//
@ -22,13 +23,18 @@ const activeIndex = ref(0);
//
const setActive = (index: number) => {
activeIndex.value = index;
router.push({ name: menuItems[index].name });
router.push(menuItems[index].path);
};
//
const backToForum = () => {
router.push('/');
};
//
onMounted(() => {
const currentRouteName = route.name as string;
const index = menuItems.findIndex(item => item.name === currentRouteName);
const currentPath = route.path;
const index = menuItems.findIndex(item => currentPath.includes(item.path));
if (index !== -1) {
activeIndex.value = index;
}
@ -37,27 +43,34 @@ onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUserInfo();
} else {
//
router.push('/login');
//
router.push({ path: '/login', query: { redirect: route.fullPath } });
}
});
// 退
const logout = () => {
userStore.logout();
router.push('/login');
};
</script>
<template>
<div class="personal-layout">
<!-- 返回论坛按钮 -->
<div class="back-bar">
<button class="back-btn" @click="backToForum">
<el-icon><ArrowLeft /></el-icon>
<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="username">{{ userStore.userInfo?.username || '用户' }}</div>
<div class="user-info">
<div class="nickname">{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}</div>
<div class="username">{{ userStore.userInfo?.username }}</div>
</div>
</div>
<ul class="menu">
@ -77,13 +90,6 @@ const logout = () => {
</div>
</li>
</ul>
<div class="sidebar-footer">
<button class="logout-btn" @click="logout">
<el-icon><switch-button /></el-icon>
<span>退出登录</span>
</button>
</div>
</div>
<!-- 主内容区 -->
@ -91,45 +97,87 @@ const logout = () => {
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
.personal-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
min-height: 100vh;
background-color: var(--bg-color);
}
.sidebar {
width: var(--sidebar-width);
height: 100%;
.back-bar {
display: flex;
align-items: center;
padding: 12px 24px;
background-color: var(--secondary-color);
transition: width var(--transition-slow);
overflow: hidden;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.back-btn {
display: flex;
flex-direction: column;
z-index: 100;
align-items: center;
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;
}
.page-title {
margin-left: 16px;
font-size: 1.2rem;
color: var(--text-primary);
}
.sidebar:hover {
width: var(--sidebar-width-expanded);
.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 {
padding: var(--spacing-lg);
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--spacing-xl);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.avatar {
width: 60px;
height: 60px;
width: 80px;
height: 80px;
border-radius: var(--border-radius-full);
overflow: hidden;
margin-bottom: var(--spacing-md);
margin-bottom: 12px;
border: 3px solid var(--primary-light);
}
@ -139,35 +187,46 @@ const logout = () => {
object-fit: cover;
}
.username {
.user-info {
text-align: center;
}
.nickname {
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 4px;
}
.username {
color: var(--text-secondary);
font-size: 0.9rem;
}
.menu {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 var(--spacing-sm);
padding: 12px;
}
.menu li {
position: relative;
margin-bottom: var(--spacing-md);
margin-bottom: 8px;
list-style: none;
cursor: pointer;
}
.menu-item {
display: flex;
align-items: center;
padding: var(--spacing-md);
padding: 12px 16px;
border-radius: var(--border-radius-md);
transition: background-color var(--transition-normal);
transition: all var(--transition-normal);
}
.menu-item:hover {
background-color: rgba(255, 255, 255, 0.5);
background-color: rgba(0, 0, 0, 0.04);
}
.menu li.active .menu-item {
@ -181,83 +240,48 @@ const logout = () => {
align-items: center;
width: 24px;
height: 24px;
margin-right: var(--spacing-md);
margin-right: 12px;
}
.title {
white-space: nowrap;
}
.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);
font-weight: 500;
}
.main-content {
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;
background-color: var(--bg-secondary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.personal-layout {
@media screen and (max-width: 768px) {
.layout-container {
flex-direction: column;
padding: 16px;
}
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
padding: var(--spacing-sm);
}
.sidebar:hover {
width: 100%;
}
.sidebar-header {
margin-bottom: 0;
padding: var(--spacing-sm);
}
.menu {
flex-direction: row;
padding: 0;
overflow-x: auto;
align-items: center;
text-align: left;
}
.menu li {
margin-right: var(--spacing-md);
.avatar {
width: 60px;
height: 60px;
margin-bottom: 0;
margin-right: 16px;
}
.sidebar-footer {
padding: var(--spacing-sm);
}
.main-content {
padding: var(--spacing-md);
.user-info {
text-align: left;
}
}
</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 PersonalLayout from '../layouts/PersonalLayout.vue';
import PublicLayout from '../layouts/PublicLayout.vue';
// 页面
import Login from '../views/Login.vue';
@ -14,87 +15,122 @@ import NotFound from '../views/NotFound.vue';
// 路由配置
const routes: Array<RouteRecordRaw> = [
// 公共页面 - 使用PublicLayout布局
{
path: '/',
component: BaseLayout,
component: PublicLayout,
children: [
// 论坛首页 - 无需登录
{
path: '',
redirect: '/login'
path: '', // 网站根路径 /
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: 'login',
path: 'courses', // URL: /courses
name: 'Courses',
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '课程表 - UniLife', requiresAuth: false }
}
]
},
// 认证相关页面 - 使用BaseLayout布局
{
path: '/',
component: BaseLayout,
children: [
{
path: 'login', // URL: /login
name: 'Login',
component: Login,
meta: {
title: '登录 - UniLife学生论坛',
requiresAuth: false
}
meta: { title: '登录/注册 - UniLife', requiresAuth: false }
}
]
},
// 个人中心页面 - 使用PersonalLayout布局
{
path: '/personal',
component: PersonalLayout,
meta: {
requiresAuth: true
},
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
path: 'home', // URL: /personal/home
name: 'PersonalHome',
component: Home,
meta: {
title: '个人主页 - UniLife学生论坛',
requiresAuth: true
}
meta: { title: '个人主页 - UniLife' }
},
{
path: 'account',
path: 'account', // URL: /personal/account
name: 'AccountManager',
component: AccountManager,
meta: {
title: '账号管理 - UniLife学生论坛',
requiresAuth: true
}
meta: { title: '账号管理 - UniLife' }
},
// 其他个人中心页面可以在这里添加
{
path: 'posts',
name: 'Posts',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面
meta: {
title: '我的帖子 - UniLife学生论坛',
requiresAuth: true
}
{
path: 'posts', // URL: /personal/posts
name: 'MyPosts',
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '我的帖子 - UniLife' }
},
{
path: 'messages',
path: 'messages', // URL: /personal/messages
name: 'Messages',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面
meta: {
title: '消息中心 - UniLife学生论坛',
requiresAuth: true
}
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '消息中心 - UniLife' }
},
{
path: 'settings',
path: 'settings', // URL: /personal/settings
name: 'Settings',
component: () => import('../views/NotFound.vue'), // 暂时使用NotFound页面
meta: {
title: '设置 - UniLife学生论坛',
requiresAuth: true
}
component: () => import('../views/NotFound.vue'), // 占位符
meta: { title: '设置 - UniLife' }
},
// 默认重定向到个人主页
{
path: '',
redirect: '/personal/home'
}
]
},
// Catch-all 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: {
title: '页面不存在 - UniLife学生论坛'
}
meta: { title: '页面不存在 - UniLife' }
}
];
@ -105,23 +141,21 @@ const router = createRouter({
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title as string || 'UniLife学生论坛';
// 检查是否需要登录权限
if (to.matched.some(record => record.meta.requiresAuth)) {
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn;
// 如果需要登录但用户未登录,重定向到登录页
if (!userStore.isLoggedIn) {
// 如果路由需要认证但用户未登录
if (to.matched.some(record => record.meta.requiresAuth) && !isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath }
name: 'Login',
query: { redirect: to.fullPath } // 保存原始路径用于登录后重定向
});
} else if ((to.name === 'Login') && isLoggedIn) {
// 如果用户已登录但尝试访问登录页面,重定向到论坛首页
next({ name: 'Forum' });
} else {
next();
}
} else {
// 正常导航
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 { ref } from 'vue';
import userApi from '../api/user';
import type { UserInfo } from '../api/user';
import type { UserInfo, UpdatePasswordParams } from '../api/user';
import { ElMessage } from 'element-plus';
export const useUserStore = defineStore('user', () => {
@ -123,19 +123,31 @@ export const useUserStore = defineStore('user', () => {
username?: string;
bio?: string;
gender?: number;
birthday?: string;
department?: string;
major?: string;
grade?: string;
}) => {
try {
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 (userInfo.value) {
userInfo.value = {
...userInfo.value,
...data
};
if (data.username !== undefined) userInfo.value.username = data.username;
if (data.bio !== undefined) userInfo.value.bio = data.bio;
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('个人资料更新成功');
@ -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 {
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) {
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">
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, computed } from 'vue';
import { useForm } from 'vee-validate';
import * as yup from 'yup';
import { ElMessage } from 'element-plus';
import { useUserStore } from '../stores';
import { useEmailCode } from '../hooks/useEmailCode';
import { Camera, Plus } from '@element-plus/icons-vue';
const userStore = useUserStore();
const { sendEmailCode, countdown } = useEmailCode();
@ -18,14 +188,16 @@ const profileForm = reactive({
username: '',
gender: 0,
bio: '',
birthday: ''
department: '',
major: '',
grade: '',
});
//
const passwordForm = reactive({
newPassword: '',
confirmPassword: '',
code: ''
code: '',
});
//
@ -43,26 +215,11 @@ const genderOptions = [
];
//
const profileValidationSchema = yup.object({
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,
const { handleSubmit: handleProfileSubmit, setValues: setProfileValues } = useForm({
initialValues: profileForm
});
const { handleSubmit: handlePasswordSubmit } = useForm({
validationSchema: passwordValidationSchema,
initialValues: passwordForm
});
@ -74,7 +231,9 @@ const fetchUserInfo = async () => {
profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0;
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';
}
};
@ -88,7 +247,9 @@ const toggleProfileEdit = () => {
profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0;
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 success = await userStore.updateProfile({
username: values.username,
bio: values.bio,
gender: values.gender,
birthday: values.birthday
});
console.log('Submitting profile with values:', values);
try {
const dataToSubmit = {
username: profileForm.username,
bio: profileForm.bio,
gender: profileForm.gender,
department: profileForm.department,
major: profileForm.major,
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 success = await userStore.updatePassword(
values.code,
values.newPassword
);
console.log('Submitting password change with passwordForm state:', passwordForm);
try {
const dataToSubmit = {
newPassword: passwordForm.newPassword,
code: passwordForm.code
};
const success = await userStore.updatePassword(dataToSubmit);
if (success) {
isPasswordEditable.value = false;
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>
<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>
.account-manager {
max-width: 1000px;
margin: 0 auto;
position: relative;
}
.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);
.account-manager-container {
padding: 20px;
max-width: 1200px;
margin: auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.card-header h2 {
font-size: var(--font-size-xl);
color: var(--primary-color);
margin: 0;
}
.card-header h3 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0;
}
.password-header {
margin-top: var(--spacing-xl);
font-size: 1.1rem;
font-weight: bold;
}
.avatar-container {
.avatar-section {
display: flex;
justify-content: center;
margin-bottom: var(--spacing-xl);
margin-bottom: 20px;
}
.avatar {
.avatar-container {
width: 120px;
height: 120px;
border-radius: var(--border-radius-full);
@ -499,7 +431,7 @@ onMounted(() => {
border: 3px solid var(--primary-light);
}
.avatar img {
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
@ -524,210 +456,38 @@ onMounted(() => {
margin-bottom: var(--spacing-xs);
}
.form-group {
margin-bottom: var(--spacing-lg);
.profile-actions,
.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;
.account-info-details p {
margin-bottom: 10px;
font-size: 0.95rem;
}
.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;
.account-info-details strong {
margin-right: 8px;
}
.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 {
display: flex;
justify-content: flex-end;
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 {
top: 200px;
left: 100px;
animation-delay: 2s;
}
.cat {
bottom: 50px;
right: 100px;
animation-delay: 3s;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
/* Responsive adjustments */
@media (max-width: 768px) {
.el-col {
margin-bottom: 20px;
}
50% {
transform: translateY(-10px);
.profile-actions,
.password-actions {
text-align: center;
}
.el-form-item {
margin-bottom: 15px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.account-container {
grid-template-columns: 1fr;
.el-card {
margin-bottom: 20px;
}
.decoration-element {
display: none;
.el-col:last-child .el-card:last-child {
margin-bottom: 0; /* Remove margin from the very last card on mobile */
}
}
</style>

@ -49,7 +49,9 @@ const handlePasswordLogin = async () => {
if (success) {
// store true
ElMessage.success('登录成功'); //
router.push('/personal');
// URLURL
const redirectUrl = router.currentRoute.value.query.redirect as string || '/';
router.push(redirectUrl);
} else {
}
} catch (error: any) {
@ -67,7 +69,9 @@ const handleCodeLogin = async () => {
if (success) {
ElMessage.success('登录成功'); //
router.push('/personal');
// URLURL
const redirectUrl = router.currentRoute.value.query.redirect as string || '/';
router.push(redirectUrl);
} else {
// store
// ElMessage.error('');
@ -90,7 +94,8 @@ const handleRegister = async () => {
if (success) {
ElMessage.success('注册成功,已自动登录'); //
router.push('/personal');
//
router.push('/');
} else {
}
} 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 vue from '@vitejs/plugin-vue'
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}
}
})

@ -42,7 +42,7 @@ public class PostController {
@Operation(summary = "获取帖子列表")
@GetMapping
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 = "size", defaultValue = "10") Integer size,
@RequestParam(value = "sort", defaultValue = "latest") String sort) {

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

@ -11,7 +11,8 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
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 Byte gender;
private String department;

@ -11,6 +11,7 @@ import com.unilife.mapper.PostMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.CreatePostDTO;
import com.unilife.model.dto.UpdatePostDTO;
import com.unilife.model.entity.Category;
import com.unilife.model.entity.Post;
import com.unilife.model.entity.User;
import com.unilife.model.vo.PostListVO;
@ -91,8 +92,15 @@ public class PostServiceImpl implements PostService {
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()
@ -103,11 +111,11 @@ public class PostServiceImpl implements PostService {
.nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null)
.categoryId(post.getCategoryId())
.categoryName("未知分类") // 实际开发中应该从category对象获取
.categoryName(categoryName) // 使用从数据库查询到的真实分类名称
.viewCount(post.getViewCount() + 1) // 已经增加了浏览次数
.likeCount(post.getLikeCount())
.commentCount(post.getCommentCount())
.isLiked(false) // 实际开发中应该查询用户是否点赞
.isLiked(isLiked) // 设置已查询的点赞状态
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.build();
@ -131,6 +139,21 @@ public class PostServiceImpl implements PostService {
// 获取分页信息
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
List<PostListVO> postListVOs = posts.stream().map(post -> {
User user = userMapper.getUserById(post.getUserId());
@ -142,7 +165,7 @@ public class PostServiceImpl implements PostService {
.nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null)
.categoryId(post.getCategoryId())
.categoryName("未知分类")
.categoryName(categoryMap.getOrDefault(post.getCategoryId(), "未知分类"))
.viewCount(post.getViewCount())
.likeCount(post.getLikeCount())
.commentCount(post.getCommentCount())

@ -33,8 +33,6 @@ import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Date;
@ -339,24 +337,49 @@ public class UserServiceImpl implements UserService {
@Override
public Result updateUserProfile(Long userId, UpdateProfileDTO profileDTO) {
// 检查用户是否存在
User user = userMapper.getUserById(userId);
if (user == null) {
User currentUser = userMapper.getUserById(userId); // Changed from findById to getUserById based on UserMapper.java
if (currentUser == null) {
return Result.error(404, "用户不存在");
}
// 更新用户信息
user.setNickname(profileDTO.getNickname());
user.setBio(profileDTO.getBio());
user.setGender(profileDTO.getGender());
user.setDepartment(profileDTO.getDepartment());
user.setMajor(profileDTO.getMajor());
user.setGrade(profileDTO.getGrade());
// 检查用户名是否更改以及是否重复
if (StringUtils.isNotEmpty(profileDTO.getUsername()) && !profileDTO.getUsername().equals(currentUser.getUsername())) {
User existingUserWithNewUsername = userMapper.findByUsername(profileDTO.getUsername());
if (existingUserWithNewUsername != null) {
return Result.error(409, "用户名已被占用,请选择其他用户名"); // 409 Conflict
}
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

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

@ -43,6 +43,12 @@
WHERE email = #{email}
</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 users
SET login_ip = #{ipLocation},
@ -107,13 +113,13 @@
<update id="updateUserProfile" parameterType="com.unilife.model.entity.User">
UPDATE users
SET nickname = #{nickname},
SET username = #{username},
nickname = #{nickname},
bio = #{bio},
gender = #{gender},
department = #{department},
major = #{major},
grade = #{grade},
updated_at = NOW()
grade = #{grade}
WHERE id = #{id}
</update>

Loading…
Cancel
Save