核心技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Vue.js | 3.5.32 | 前端核心框架(Vue 3) |
| Vue Router | 4.5.1 | Vue 官方路由管理器 |
| Pinia | 2.3.1 | Vue 3 官方状态管理(替代 Vuex) |
| Axios | 1.5.0 | HTTP 请求库 |
| Vuetify | 3.7.8 | Material Design UI 组件库 |
| Vue Quill Editor | 3.0.6 | 富文本编辑器 |
| Moment.js | 2.30.1 | 日期处理库 |
# 检查是否已安装 node -v npm -v # 如果没有安装,去 https://nodejs.org 下载 LTS 版本
# 全局安装 Vue CLI npm install -g @vue/cli # 检查版本 vue --version
在父目录下新建web项目
idea旧版本选择静态web

新版本选择vue
创建完成后目录

进入前端目录命令窗

cd forum-frontend # 安装 axios npm install axios # 安装 quill-editor npm install vue-quill-editor # 安装 moment.js(日期格式化) npm install moment
forum-frontend/ ├── public/ │ └── index.html ├── src/ │ ├── main.js │ ├── App.vue │ ├── plugins/ │ │ ├── vuetify.js │ │ └── quill-editor.js │ ├── api/ │ │ ├── index.js │ │ ├── auth.js │ │ ├── post.js │ │ └── comment.js │ ├── router/ │ │ └── index.js │ ├── store/ │ │ └── index.js │ ├── views/ │ │ ├── Login.vue │ │ ├── Register.vue │ │ ├── Home.vue │ │ ├── PostDetail.vue │ │ ├── PostCreate.vue │ │ └── Profile.vue │ ├── components/ │ │ ├── Header.vue │ │ ├── PostCard.vue │ │ └── CommentItem.vue │ └── styles/ │ └── global.scss ├── vue.config.js └── package.json
// vue.config.js module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } }, css: { loaderOptions: { sass: { additionalData: `@import "@/styles/global.scss";` } } } }
// src/plugins/vuetify.js import Vue from 'vue' import Vuetify from 'vuetify/lib' import 'vuetify/dist/vuetify.min.css' Vue.use(Vuetify) export default new Vuetify({ theme: { themes: { light: { primary: '#1976D2', secondary: '#424242', accent: '#82B1FF', error: '#FF5252', info: '#2196F3', success: '#4CAF50', warning: '#FFC107' } } } })
// src/plugins/quill-editor.js import Vue from 'vue' import VueQuillEditor from 'vue-quill-editor' import 'quill/dist/quill.core.css' import 'quill/dist/quill.snow.css' import 'quill/dist/quill.bubble.css' Vue.use(VueQuillEditor)
// src/api/index.js import axios from 'axios' import store from '@/store' const service = axios.create({ baseURL: '/api', timeout: 30000 }) // 请求拦截器 service.interceptors.request.use( config => { const token = store.state.user.token if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }, error => { return Promise.reject(error) } ) // 响应拦截器 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { // 统一错误处理 console.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { // Token 过期,清除登录状态 store.commit('user/LOGOUT') window.location.href = '/login' } return Promise.reject(error) } ) export default service
// src/api/auth.js import request from './index' export const authApi = { // 登录 login(data) { return request.post('/auth/login', data) }, // 注册 register(data) { return request.post('/auth/register', data) }, // 获取当前用户 getCurrentUser() { return request.get('/auth/current') } }
// src/api/post.js import request from './index' export const postApi = { // 发布帖子 create(data) { return request.post('/posts', data) }, // 获取帖子详情 getDetail(id) { return request.get(`/posts/${id}`) }, // 分页获取帖子列表 getPage(params) { return request.get('/posts/page', { params }) }, // 更新帖子 update(id, data) { return request.put(`/posts/${id}`, data) }, // 删除帖子 delete(id) { return request.delete(`/posts/${id}`) }, // 置顶帖子 stick(id) { return request.put(`/posts/${id}/stick`) }, // 设为精华 essence(id) { return request.put(`/posts/${id}/essence`) } }
// src/api/comment.js import request from './index' export const commentApi = { // 发布评论 create(data) { return request.post('/comments', data) }, // 获取帖子评论列表 getByPostId(postId, params) { return request.get(`/comments/post/${postId}`, { params }) }, // 删除评论 delete(id) { return request.delete(`/comments/${id}`) } }
// src/store/index.js import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ modules: { user: { namespaced: true, state: { user: null, token: localStorage.getItem('token') }, mutations: { SET_USER(state, user) { state.user = user if (user && user.token) { state.token = user.token localStorage.setItem('token', user.token) localStorage.setItem('user', JSON.stringify(user)) } }, LOGOUT(state) { state.user = null state.token = null localStorage.removeItem('token') localStorage.removeItem('user') } }, actions: { setUser({ commit }, user) { commit('SET_USER', user) }, logout({ commit }) { commit('LOGOUT') }, loadFromStorage({ commit }) { const token = localStorage.getItem('token') const user = localStorage.getItem('user') if (token && user) { commit('SET_USER', JSON.parse(user)) } } }, getters: { isLoggedIn: state => !!state.token, currentUser: state => state.user } } } })
// src/router/index.js import Vue from 'vue' import VueRouter from 'vue-router' import store from '@/store' Vue.use(VueRouter) const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { requiresAuth: false } }, { path: '/register', name: 'Register', component: () => import('@/views/Register.vue'), meta: { requiresAuth: false } }, { path: '/', name: 'Home', component: () => import('@/views/Home.vue'), meta: { requiresAuth: true } }, { path: '/post/:id', name: 'PostDetail', component: () => import('@/views/PostDetail.vue'), meta: { requiresAuth: true } }, { path: '/post/create', name: 'PostCreate', component: () => import('@/views/PostCreate.vue'), meta: { requiresAuth: true } }, { path: '/profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { requiresAuth: true } } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) // 路由守卫 router.beforeEach((to, from, next) => { const isLoggedIn = store.getters['user/isLoggedIn'] if (to.meta.requiresAuth && !isLoggedIn) { next('/login') } else { next() } }) export default router
// src/styles/global.scss * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; } .main-content { max-width: 1200px; margin: 80px auto 20px; padding: 0 20px; min-height: calc(100vh - 100px); } .markdown-body { font-size: 16px; line-height: 1.6; word-wrap: break-word; } .markdown-body pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; } .markdown-body code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-family: 'Courier New', monospace; }
<!-- src/components/Header.vue --> <template> <v-app-bar app color="primary" dark> <v-app-bar-nav-icon @click="drawer = !drawer" class="d-md-none"></v-app-bar-nav-icon> <v-toolbar-title @click="$router.push('/')" style="cursor: pointer"> 论坛系统 </v-toolbar-title> <v-spacer></v-spacer> <!-- PC端导航 --> <div class="d-none d-md-flex align-center"> <v-btn text @click="$router.push('/')">首页</v-btn> <template v-if="isLoggedIn"> <v-btn text @click="$router.push('/post/create')">发布帖子</v-btn> <v-menu offset-y> <template v-slot:activator="{ on, attrs }"> <v-btn text v-bind="attrs" v-on="on"> <v-avatar size="32" class="mr-2"> <v-icon>mdi-account-circle</v-icon> </v-avatar> {{ currentUser.nickname || currentUser.username }} <v-icon right>mdi-chevron-down</v-icon> </v-btn> </template> <v-list> <v-list-item @click="$router.push('/profile')"> <v-list-item-title>个人中心</v-list-item-title> </v-list-item> <v-list-item @click="handleLogout"> <v-list-item-title>退出登录</v-list-item-title> </v-list-item> </v-list> </v-menu> </template> <template v-else> <v-btn text @click="$router.push('/login')">登录</v-btn> <v-btn text @click="$router.push('/register')">注册</v-btn> </template> </div> <!-- 移动端抽屉菜单 --> <v-navigation-drawer v-model="drawer" temporary absolute> <v-list nav> <v-list-item @click="navigate('/')"> <v-list-item-icon><v-icon>mdi-home</v-icon></v-list-item-icon> <v-list-item-title>首页</v-list-item-title> </v-list-item> <v-list-item v-if="isLoggedIn" @click="navigate('/post/create')"> <v-list-item-icon><v-icon>mdi-pencil</v-icon></v-list-item-icon> <v-list-item-title>发布帖子</v-list-item-title> </v-list-item> <v-list-item v-if="isLoggedIn" @click="navigate('/profile')"> <v-list-item-icon><v-icon>mdi-account</v-icon></v-list-item-icon> <v-list-item-title>个人中心</v-list-item-title> </v-list-item> <v-list-item v-if="!isLoggedIn" @click="navigate('/login')"> <v-list-item-icon><v-icon>mdi-login</v-icon></v-list-item-icon> <v-list-item-title>登录</v-list-item-title> </v-list-item> <v-list-item v-if="!isLoggedIn" @click="navigate('/register')"> <v-list-item-icon><v-icon>mdi-account-plus</v-icon></v-list-item-icon> <v-list-item-title>注册</v-list-item-title> </v-list-item> <v-list-item v-if="isLoggedIn" @click="handleLogout"> <v-list-item-icon><v-icon>mdi-logout</v-icon></v-list-item-icon> <v-list-item-title>退出登录</v-list-item-title> </v-list-item> </v-list> </v-navigation-drawer> </v-app-bar> </template> <script> import { mapGetters, mapActions } from 'vuex' export default { name: 'Header', data() { return { drawer: false } }, computed: { ...mapGetters('user', ['isLoggedIn', 'currentUser']) }, methods: { ...mapActions('user', ['logout']), navigate(path) { this.drawer = false this.$router.push(path) }, handleLogout() { this.logout() this.$router.push('/login') } } } </script> <style scoped> .v-toolbar-title { cursor: pointer; } </style>
<!-- src/components/PostCard.vue --> <template> <v-card class="post-card mb-4" elevation="2" hover @click="$emit('click')"> <v-card-title class="pb-2"> <div class="d-flex align-center"> <v-avatar size="40" class="mr-3"> <v-icon large>mdi-account-circle</v-icon> </v-avatar> <div> <div class="subtitle-2">{{ post.nickname || '用户' + post.userId }}</div> <div class="caption grey--text">{{ formatTime(post.createdTime) }}</div> </div> <v-spacer></v-spacer> <div> <v-chip v-if="post.type === 3" small color="red" text-color="white">置顶</v-chip> <v-chip v-else-if="post.type === 2" small color="orange" text-color="white">精华</v-chip> </div> </div> </v-card-title> <v-card-title class="pt-0"> <div class="post-title">{{ post.title }}</div> </v-card-title> <v-card-text> <div class="post-summary"> {{ getSummary(post.content) }} </div> </v-card-text> <v-card-actions> <v-chip small outlined> <v-icon left small>mdi-eye</v-icon> {{ post.viewCount || 0 }} </v-chip> <v-chip small outlined class="ml-2"> <v-icon left small>mdi-message</v-icon> {{ post.replyCount || 0 }} </v-chip> <v-chip small outlined class="ml-2"> <v-icon left small>mdi-thumb-up</v-icon> {{ post.likeCount || 0 }} </v-chip> <v-spacer></v-spacer> <v-chip small color="grey lighten-2"> {{ post.categoryName || '综合' }} </v-chip> </v-card-actions> </v-card> </template> <script> import moment from 'moment' export default { name: 'PostCard', props: { post: { type: Object, required: true } }, methods: { formatTime(time) { if (!time) return '' return moment(time).fromNow() }, getSummary(content) { if (!content) return '' const text = content.replace(/<[^>]*>/g, '') return text.length > 150 ? text.substring(0, 150) + '...' : text } } } </script> <style scoped> .post-card { cursor: pointer; transition: transform 0.2s; } .post-card:hover { transform: translateY(-2px); } .post-title { font-size: 18px; font-weight: 500; color: #333; } .post-summary { color: #666; line-height: 1.6; } </style>
<!-- src/components/CommentItem.vue --> <template> <v-card class="comment-item mb-3" elevation="1"> <v-card-text> <div class="d-flex align-center mb-3"> <v-avatar size="32" class="mr-2"> <v-icon small>mdi-account-circle</v-icon> </v-avatar> <div> <div class="subtitle-2">{{ comment.nickname || '用户' + comment.userId }}</div> <div class="caption grey--text">{{ formatTime(comment.createdTime) }}</div> </div> <v-spacer></v-spacer> <v-btn icon small @click="$emit('reply')" v-if="showReply"> <v-icon small>mdi-reply</v-icon> </v-btn> </div> <div class="comment-content" v-html="comment.content"></div> <div class="d-flex align-center mt-3"> <v-btn icon small @click="handleLike"> <v-icon small :color="isLiked ? 'red' : ''">mdi-heart</v-icon> </v-btn> <span class="caption ml-1">{{ comment.likeCount || 0 }}</span> </div> <!-- 子评论 --> <div v-if="comment.children && comment.children.length" class="child-comments mt-3"> <comment-item v-for="child in comment.children" :key="child.id" :comment="child" :show-reply="false" @reply="$emit('reply', child)" /> </div> </v-card-text> </v-card> </template> <script> import moment from 'moment' export default { name: 'CommentItem', props: { comment: { type: Object, required: true }, showReply: { type: Boolean, default: true } }, data() { return { isLiked: false } }, methods: { formatTime(time) { if (!time) return '' return moment(time).fromNow() }, handleLike() { this.isLiked = !this.isLiked this.$emit('like', this.comment.id) } } } </script> <style scoped> .comment-item { background: #fafafa; } .comment-content { font-size: 14px; line-height: 1.5; color: #333; } .child-comments { margin-left: 40px; padding-left: 20px; border-left: 2px solid #e0e0e0; } </style>
<!-- src/views/Login.vue --> <template> <v-container fluid fill-height class="login-container"> <v-row align="center" justify="center"> <v-col cols="12" sm="8" md="4"> <v-card class="login-card elevation-12"> <v-card-title class="justify-center"> <h2 class="primary--text">论坛系统</h2> </v-card-title> <v-card-subtitle class="text-center">欢迎回来,请登录您的账号</v-card-subtitle> <v-card-text> <v-alert v-if="errorMessage" type="error" dense dismissible> {{ errorMessage }} </v-alert> <v-form ref="form" v-model="valid"> <v-text-field v-model="form.username" label="用户名" prepend-icon="mdi-account" :rules="[v => !!v || '用户名不能为空']" outlined ></v-text-field> <v-text-field v-model="form.password" label="密码" prepend-icon="mdi-lock" :type="showPassword ? 'text' : 'password'" :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showPassword = !showPassword" :rules="[v => !!v || '密码不能为空']" outlined @keyup.enter="handleLogin" ></v-text-field> </v-form> </v-card-text> <v-card-actions class="px-4 pb-4"> <v-btn color="primary" block large :loading="loading" @click="handleLogin"> 登录 </v-btn> </v-card-actions> <v-card-text class="text-center"> 还没有账号? <router-link to="/register">立即注册</router-link> </v-card-text> </v-card> </v-col> </v-row> </v-container> </template> <script> import { mapActions } from 'vuex' import { authApi } from '@/api/auth' export default { name: 'Login', data() { return { valid: false, showPassword: false, loading: false, errorMessage: '', form: { username: '', password: '' } } }, methods: { ...mapActions('user', ['setUser']), async handleLogin() { if (!this.$refs.form.validate()) return this.loading = true this.errorMessage = '' try { const res = await authApi.login(this.form) if (res.code === 200) { this.setUser(res.data) this.$router.push('/') } } catch (error) { this.errorMessage = error.message || '登录失败,请稍后重试' } finally { this.loading = false } } } } </script> <style scoped> .login-container { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .login-card { border-radius: 16px; } </style>
<!-- src/views/Register.vue --> <template> <v-container fluid fill-height class="register-container"> <v-row align="center" justify="center"> <v-col cols="12" sm="8" md="5"> <v-card class="register-card elevation-12"> <v-card-title class="justify-center"> <h2 class="primary--text">注册新账号</h2> </v-card-title> <v-card-subtitle class="text-center">加入论坛,分享知识</v-card-subtitle> <v-card-text> <v-alert v-if="errorMessage" type="error" dense dismissible> {{ errorMessage }} </v-alert> <v-alert v-if="successMessage" type="success" dense> {{ successMessage }} </v-alert> <v-form ref="form" v-model="valid"> <v-text-field v-model="form.username" label="用户名" prepend-icon="mdi-account" :rules="usernameRules" outlined ></v-text-field> <v-text-field v-model="form.email" label="邮箱" prepend-icon="mdi-email" :rules="emailRules" outlined ></v-text-field> <v-text-field v-model="form.phone" label="手机号" prepend-icon="mdi-phone" :rules="phoneRules" outlined ></v-text-field> <v-text-field v-model="form.password" label="密码" prepend-icon="mdi-lock" :type="showPassword ? 'text' : 'password'" :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showPassword = !showPassword" :rules="passwordRules" outlined ></v-text-field> <v-text-field v-model="form.confirmPassword" label="确认密码" prepend-icon="mdi-lock-check" :type="showConfirmPassword ? 'text' : 'password'" :append-icon="showConfirmPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showConfirmPassword = !showConfirmPassword" :rules="confirmPasswordRules" outlined ></v-text-field> <v-text-field v-model="form.nickname" label="昵称" prepend-icon="mdi-card-account-details" outlined ></v-text-field> </v-form> </v-card-text> <v-card-actions class="px-4 pb-4"> <v-btn color="primary" block large :loading="loading" @click="handleRegister"> 注册 </v-btn> </v-card-actions> <v-card-text class="text-center"> 已有账号? <router-link to="/login">立即登录</router-link> </v-card-text> </v-card> </v-col> </v-row> </v-container> </template> <script> import { authApi } from '@/api/auth' export default { name: 'Register', data() { return { valid: false, showPassword: false, showConfirmPassword: false, loading: false, errorMessage: '', successMessage: '', form: { username: '', email: '', phone: '', password: '', confirmPassword: '', nickname: '' }, usernameRules: [ v => !!v || '用户名不能为空', v => (v && v.length >= 3) || '用户名长度不能小于3', v => (v && v.length <= 20) || '用户名长度不能大于20', v => /^[a-zA-Z0-9_]+$/.test(v) || '用户名只能包含字母、数字和下划线' ], emailRules: [ v => !v || /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) || '邮箱格式不正确' ], phoneRules: [ v => !v || /^1[3-9]\d{9}$/.test(v) || '手机号格式不正确' ], passwordRules: [ v => !!v || '密码不能为空', v => (v && v.length >= 6) || '密码长度不能小于6' ], confirmPasswordRules: [ v => !!v || '请确认密码', v => v === this.form.password || '两次输入的密码不一致' ] } }, methods: { async handleRegister() { if (!this.$refs.form.validate()) return this.loading = true this.errorMessage = '' try { const res = await authApi.register(this.form) if (res.code === 200) { this.successMessage = '注册成功!即将跳转到登录页...' setTimeout(() => { this.$router.push('/login') }, 1500) } } catch (error) { this.errorMessage = error.message || '注册失败,请稍后重试' } finally { this.loading = false } } } } </script> <style scoped> .register-container { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .register-card { border-radius: 16px; } </style>
<!-- src/views/Home.vue --> <template> <div class="home"> <v-container> <!-- 欢迎横幅 --> <v-row> <v-col cols="12"> <v-card color="primary" dark class="mb-6 welcome-card"> <v-card-title class="headline"> 欢迎来到论坛,{{ currentUser.nickname || currentUser.username }}! </v-card-title> <v-card-subtitle> 分享知识,交流思想,结识朋友 </v-card-subtitle> </v-card> </v-col> </v-row> <!-- 操作栏 --> <v-row> <v-col cols="12"> <div class="d-flex justify-space-between align-center mb-4"> <h3>最新帖子</h3> <v-btn color="primary" to="/post/create"> <v-icon left>mdi-pencil</v-icon> 发布新帖 </v-btn> </div> </v-col> </v-row> <!-- 帖子列表 --> <v-row> <v-col cols="12"> <v-progress-circular v-if="loading" indeterminate color="primary" class="d-block mx-auto"></v-progress-circular> <PostCard v-for="post in posts" :key="post.id" :post="post" @click="goToDetail(post.id)" /> <v-card v-if="!loading && posts.length === 0" class="text-center py-8"> <v-icon size="64" color="grey lighten-1">mdi-forum-outline</v-icon> <div class="mt-2 grey--text">暂无帖子,快来发布第一个吧!</div> </v-card> </v-col> </v-row> <!-- 分页 --> <v-row v-if="total > pageSize"> <v-col cols="12"> <div class="d-flex justify-center mt-4"> <v-pagination v-model="pageNum" :length="totalPages" :total-visible="7" @input="loadPosts" ></v-pagination> </div> </v-col> </v-row> </v-container> </div> </template> <script> import { mapGetters } from 'vuex' import { postApi } from '@/api/post' import PostCard from '@/components/PostCard.vue' export default { name: 'Home', components: { PostCard }, data() { return { loading: false, posts: [], pageNum: 1, pageSize: 10, total: 0 } }, computed: { ...mapGetters('user', ['currentUser']), totalPages() { return Math.ceil(this.total / this.pageSize) } }, mounted() { this.loadPosts() }, methods: { async loadPosts() { this.loading = true try { const res = await postApi.getPage({ pageNum: this.pageNum, pageSize: this.pageSize }) if (res.code === 200) { this.posts = res.data.records || [] this.total = res.data.total || 0 } } catch (error) { console.error('加载帖子失败', error) } finally { this.loading = false } }, goToDetail(id) { this.$router.push(`/post/${id}`) } } } </script> <style scoped> .welcome-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; } </style>
<!-- src/views/PostCreate.vue --> <template> <div class="post-create"> <v-container> <v-row> <v-col cols="12"> <v-card> <v-card-title class="primary white--text"> <v-icon dark left>mdi-pencil</v-icon> 发布新帖子 </v-card-title> <v-card-text class="pa-6"> <v-alert v-if="errorMessage" type="error" dense dismissible class="mb-4"> {{ errorMessage }} </v-alert> <v-form ref="form" v-model="valid"> <!-- 版块选择 --> <v-select v-model="form.categoryId" :items="categories" item-text="name" item-value="id" label="选择版块" prepend-icon="mdi-folder" :rules="[v => !!v || '请选择版块']" outlined ></v-select> <!-- 帖子标题 --> <v-text-field v-model="form.title" label="帖子标题" prepend-icon="mdi-format-title" :rules="titleRules" counter="100" outlined ></v-text-field> <!-- 帖子类型 --> <v-radio-group v-model="form.type" row> <v-radio label="普通帖" :value="1"></v-radio> <v-radio label="精华帖" :value="2"></v-radio> <v-radio label="置顶帖" :value="3"></v-radio> </v-radio-group> <!-- 标签输入 --> <v-combobox v-model="form.tags" label="标签" prepend-icon="mdi-tag" multiple small-chips deletable-chips outlined placeholder="输入标签后按回车添加" ></v-combobox> <!-- 富文本编辑器 --> <div class="mb-4"> <label class="v-label theme--light mb-2">帖子内容</label> <quill-editor v-model="form.content" ref="myQuillEditor" :options="editorOption" @change="onEditorChange" ></quill-editor> </div> <!-- Markdown 内容 --> <v-textarea v-model="form.contentMd" label="Markdown内容(可选)" prepend-icon="mdi-language-markdown" rows="5" outlined hint="支持Markdown格式,优先级高于富文本" ></v-textarea> </v-form> </v-card-text> <v-card-actions class="pa-4"> <v-spacer></v-spacer> <v-btn @click="cancel" outlined>取消</v-btn> <v-btn color="primary" :loading="submitting" :disabled="!valid" @click="handleSubmit"> 发布帖子 </v-btn> </v-card-actions> </v-card> </v-col> </v-row> </v-container> </div> </template> <script> import { postApi } from '@/api/post' export default { name: 'PostCreate', data() { return { valid: false, submitting: false, errorMessage: '', form: { categoryId: null, title: '', type: 1, tags: [], content: '', contentMd: '' }, categories: [ { id: 1, name: '技术交流' }, { id: 2, name: '生活闲聊' }, { id: 3, name: '问题求助' }, { id: 4, name: '资源分享' } ], titleRules: [ v => !!v || '标题不能为空', v => (v && v.length >= 5) || '标题长度不能小于5', v => (v && v.length <= 100) || '标题长度不能大于100' ], editorOption: { theme: 'snow', placeholder: '请输入帖子内容...', modules: { toolbar: [ ['bold', 'italic', 'underline', 'strike'], ['blockquote', 'code-block'], [{ header: 1 }, { header: 2 }], [{ list: 'ordered' }, { list: 'bullet' }], [{ script: 'sub' }, { script: 'super' }], [{ indent: '-1' }, { indent: '+1' }], [{ direction: 'rtl' }], [{ size: ['small', false, 'large', 'huge'] }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], [{ font: [] }], [{ align: [] }], ['clean'], ['link', 'image', 'video'] ] } } } }, methods: { onEditorChange({ html, text }) { this.form.content = html }, async handleSubmit() { if (!this.$refs.form.validate()) return this.submitting = true this.errorMessage = '' try { // 将标签数组转换为逗号分隔的字符串 const submitData = { ...this.form, tags: this.form.tags.join(',') } const res = await postApi.create(submitData) if (res.code === 200) { this.$router.push(`/post/${res.data.id}`) } } catch (error) { this.errorMessage = error.message || '发布失败,请稍后重试' } finally { this.submitting = false } }, cancel() { this.$router.go(-1) } } } </script> <style scoped> .post-create { padding-bottom: 40px; } .ql-editor { min-height: 300px; } </style>
<!-- src/views/PostDetail.vue --> <template> <div class="post-detail"> <v-container> <v-row> <v-col cols="12"> <!-- 加载中 --> <div v-if="loading" class="text-center py-8"> <v-progress-circular indeterminate color="primary" size="64"></v-progress-circular> </div> <!-- 帖子内容 --> <template v-else> <v-card> <!-- 帖子头部 --> <v-card-title class="post-title"> {{ post.title }} <v-spacer></v-spacer> <v-chip v-if="post.type === 3" color="red" text-color="white" small>置顶</v-chip> <v-chip v-else-if="post.type === 2" color="orange" text-color="white" small>精华</v-chip> </v-card-title> <v-card-subtitle> <div class="d-flex align-center"> <v-avatar size="40" class="mr-3"> <v-icon large>mdi-account-circle</v-icon> </v-avatar> <div> <div class="subtitle-1">{{ post.nickname || '用户' + post.userId }}</div> <div class="caption grey--text"> 发布于 {{ formatTime(post.createdTime) }} <span v-if="post.updatedTime !== post.createdTime"> · 最后编辑于 {{ formatTime(post.updatedTime) }} </span> </div> </div> <v-spacer></v-spacer> <div class="stats"> <v-chip small outlined class="mr-2"> <v-icon left small>mdi-eye</v-icon> {{ post.viewCount || 0 }} </v-chip> <v-chip small outlined> <v-icon left small>mdi-message</v-icon> {{ post.replyCount || 0 }} </v-chip> </div> </div> </v-card-subtitle> <!-- 操作按钮 --> <v-card-actions v-if="canEdit"> <v-btn small text color="primary" @click="editPost"> <v-icon left small>mdi-pencil</v-icon> 编辑 </v-btn> <v-btn small text color="error" @click="deletePost"> <v-icon left small>mdi-delete</v-icon> 删除 </v-btn> </v-card-actions> <v-divider></v-divider> <!-- 帖子内容 --> <v-card-text> <div class="post-content" v-html="post.content"></div> <!-- 标签 --> <div v-if="post.tags && post.tags.length" class="mt-4"> <v-chip v-for="tag in post.tags.split(',')" :key="tag" small class="mr-2" color="grey lighten-2"> #{{ tag }} </v-chip> </div> </v-card-text> <!-- 互动按钮 --> <v-card-actions> <v-btn text :color="isLiked ? 'red' : ''" @click="handleLike"> <v-icon left>mdi-heart</v-icon> {{ post.likeCount || 0 }} </v-btn> <v-btn text :color="isCollected ? 'amber' : ''" @click="handleCollect"> <v-icon left>mdi-star</v-icon> {{ post.collectCount || 0 }} </v-btn> <v-btn text @click="scrollToComment"> <v-icon left>mdi-message</v-icon> 回复 </v-btn> </v-card-actions> </v-card> <!-- 评论区域 --> <v-card class="mt-4" ref="commentSection"> <v-card-title> 评论({{ totalComments }}) </v-card-title> <v-divider></v-divider> <!-- 发表评论 --> <v-card-text> <v-form ref="commentForm"> <v-textarea v-model="commentContent" label="发表你的评论..." rows="3" outlined :rules="[v => !!v || '评论内容不能为空']" ></v-textarea> <v-btn color="primary" :loading="commentSubmitting" @click="submitComment"> 发表评论 </v-btn> </v-form> </v-card-text> <v-divider></v-divider> <!-- 评论列表 --> <v-card-text v-if="commentsLoading"> <div class="text-center py-4"> <v-progress-circular indeterminate color="primary"></v-progress-circular> </div> </v-card-text> <v-card-text v-else> <CommentItem v-for="comment in comments" :key="comment.id" :comment="comment" @reply="replyToComment" @like="likeComment" /> <div v-if="comments.length === 0" class="text-center py-8 grey--text"> 暂无评论,快来抢沙发吧! </div> </v-card-text> <!-- 评论分页 --> <v-card-actions v-if="totalComments > commentPageSize"> <v-spacer></v-spacer> <v-pagination v-model="commentPageNum" :length="commentTotalPages" :total-visible="5" @input="loadComments" ></v-pagination> </v-card-actions> </v-card> </template> </v-col> </v-row> </v-container> </div> </template> <script> import { mapGetters } from 'vuex' import { postApi } from '@/api/post' import { commentApi } from '@/api/comment' import CommentItem from '@/components/CommentItem.vue' import moment from 'moment' export default { name: 'PostDetail', components: { CommentItem }, data() { return { loading: true, post: {}, isLiked: false, isCollected: false, commentContent: '', commentSubmitting: false, commentsLoading: false, comments: [], commentPageNum: 1, commentPageSize: 10, totalComments: 0, replyTarget: null } }, computed: { ...mapGetters('user', ['currentUser', 'isLoggedIn']), postId() { return this.$route.params.id }, canEdit() { return this.currentUser && ( this.currentUser.id === this.post.userId || this.currentUser.role === 'admin' ) }, commentTotalPages() { return Math.ceil(this.totalComments / this.commentPageSize) } }, mounted() { this.loadPost() this.loadComments() }, methods: { formatTime(time) { if (!time) return '' return moment(time).format('YYYY-MM-DD HH:mm:ss') }, async loadPost() { this.loading = true try { const res = await postApi.getDetail(this.postId) if (res.code === 200) { this.post = res.data } } catch (error) { console.error('加载帖子失败', error) } finally { this.loading = false } }, async loadComments() { this.commentsLoading = true try { const res = await commentApi.getByPostId(this.postId, { pageNum: this.commentPageNum, pageSize: this.commentPageSize }) if (res.code === 200) { this.comments = res.data.records || [] this.totalComments = res.data.total || 0 } } catch (error) { console.error('加载评论失败', error) } finally { this.commentsLoading = false } }, async submitComment() { if (!this.commentContent.trim()) { this.$refs.commentForm.validate() return } this.commentSubmitting = true try { const data = { postId: this.postId, content: this.commentContent } if (this.replyTarget) { data.parentId = this.replyTarget.id data.replyUserId = this.replyTarget.userId } const res = await commentApi.create(data) if (res.code === 200) { this.commentContent = '' this.replyTarget = null this.commentPageNum = 1 await this.loadComments() await this.loadPost() // 更新评论数 this.$refs.commentSection.scrollIntoView({ behavior: 'smooth' }) } } catch (error) { console.error('发表评论失败', error) } finally { this.commentSubmitting = false } }, replyToComment(comment) { this.replyTarget = comment this.commentContent = `@${comment.nickname || '用户' + comment.userId} ` this.$refs.commentSection.scrollIntoView({ behavior: 'smooth' }) }, async handleLike() { // 点赞功能(需要后端实现) this.isLiked = !this.isLiked if (this.isLiked) { this.post.likeCount = (this.post.likeCount || 0) + 1 } else { this.post.likeCount = (this.post.likeCount || 0) - 1 } }, async handleCollect() { // 收藏功能(需要后端实现) this.isCollected = !this.isCollected if (this.isCollected) { this.post.collectCount = (this.post.collectCount || 0) + 1 } else { this.post.collectCount = (this.post.collectCount || 0) - 1 } }, async likeComment(commentId) { // 评论点赞(需要后端实现) console.log('点赞评论', commentId) }, editPost() { this.$router.push(`/post/${this.postId}/edit`) }, async deletePost() { const confirm = await this.$confirm('确定要删除这篇帖子吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).catch(() => false) if (confirm) { try { const res = await postApi.delete(this.postId) if (res.code === 200) { this.$router.push('/') } } catch (error) { console.error('删除失败', error) } } }, scrollToComment() { this.$refs.commentSection.scrollIntoView({ behavior: 'smooth' }) } } } </script> <style scoped> .post-detail { padding-bottom: 40px; } .post-title { font-size: 24px; font-weight: bold; flex-wrap: wrap; } .post-content { font-size: 16px; line-height: 1.8; } .post-content img { max-width: 100%; height: auto; } .stats { display: flex; gap: 8px; } </style>
<!-- src/views/Profile.vue --> <template> <div class="profile"> <v-container> <v-row> <v-col cols="12" md="4"> <!-- 个人信息卡片 --> <v-card> <v-card-title class="primary white--text"> <v-icon dark left>mdi-account-circle</v-icon> 个人资料 </v-card-title> <v-card-text class="text-center py-6"> <v-avatar size="120" class="mb-4"> <v-icon size="120">mdi-account-circle</v-icon> </v-avatar> <h3>{{ user.nickname || user.username }}</h3> <div class="grey--text">@{{ user.username }}</div> </v-card-text> <v-divider></v-divider> <v-list dense> <v-list-item> <v-list-item-icon> <v-icon>mdi-email</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>邮箱</v-list-item-title> <v-list-item-subtitle>{{ user.email || '未设置' }}</v-list-item-subtitle> </v-list-item-content> </v-list-item> <v-list-item> <v-list-item-icon> <v-icon>mdi-phone</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>手机号</v-list-item-title> <v-list-item-subtitle>{{ user.phone || '未设置' }}</v-list-item-subtitle> </v-list-item-content> </v-list-item> <v-list-item> <v-list-item-icon> <v-icon>mdi-calendar</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>注册时间</v-list-item-title> <v-list-item-subtitle>{{ formatTime(user.createdTime) }}</v-list-item-subtitle> </v-list-item-content> </v-list-item> </v-list> </v-card> </v-col> <v-col cols="12" md="8"> <!-- 统计数据卡片 --> <v-row> <v-col cols="6" sm="3"> <v-card class="text-center pa-4"> <div class="stat-number">{{ user.postCount || 0 }}</div> <div class="stat-label">帖子</div> </v-card> </v-col> <v-col cols="6" sm="3"> <v-card class="text-center pa-4"> <div class="stat-number">{{ user.replyCount || 0 }}</div> <div class="stat-label">回复</div> </v-card> </v-col> <v-col cols="6" sm="3"> <v-card class="text-center pa-4"> <div class="stat-number">{{ user.followerCount || 0 }}</div> <div class="stat-label">粉丝</div> </v-card> </v-col> <v-col cols="6" sm="3"> <v-card class="text-center pa-4"> <div class="stat-number">{{ user.followingCount || 0 }}</div> <div class="stat-label">关注</div> </v-card> </v-col> </v-row> <!-- 编辑资料表单 --> <v-card class="mt-4"> <v-card-title> <v-icon left>mdi-account-edit</v-icon> 编辑资料 </v-card-title> <v-divider></v-divider> <v-card-text> <v-alert v-if="updateSuccess" type="success" dense dismissible> 资料更新成功! </v-alert> <v-alert v-if="updateError" type="error" dense dismissible> {{ updateError }} </v-alert> <v-form ref="profileForm"> <v-text-field v-model="editForm.nickname" label="昵称" prepend-icon="mdi-card-account-details" outlined ></v-text-field> <v-text-field v-model="editForm.email" label="邮箱" prepend-icon="mdi-email" :rules="emailRules" outlined ></v-text-field> <v-text-field v-model="editForm.phone" label="手机号" prepend-icon="mdi-phone" :rules="phoneRules" outlined ></v-text-field> <v-textarea v-model="editForm.signature" label="个性签名" prepend-icon="mdi-format-quote-open" rows="3" outlined counter="200" ></v-textarea> </v-form> </v-card-text> <v-card-actions> <v-spacer></v-spacer> <v-btn color="primary" :loading="updating" @click="updateProfile"> 保存修改 </v-btn> </v-card-actions> </v-card> <!-- 修改密码 --> <v-card class="mt-4"> <v-card-title> <v-icon left>mdi-lock-reset</v-icon> 修改密码 </v-card-title> <v-divider></v-divider> <v-card-text> <v-alert v-if="pwdSuccess" type="success" dense dismissible> 密码修改成功,请重新登录! </v-alert> <v-alert v-if="pwdError" type="error" dense dismissible> {{ pwdError }} </v-alert> <v-form ref="passwordForm"> <v-text-field v-model="passwordForm.oldPassword" label="当前密码" prepend-icon="mdi-lock" :type="showOldPwd ? 'text' : 'password'" :append-icon="showOldPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showOldPwd = !showOldPwd" :rules="[v => !!v || '请输入当前密码']" outlined ></v-text-field> <v-text-field v-model="passwordForm.newPassword" label="新密码" prepend-icon="mdi-lock-plus" :type="showNewPwd ? 'text' : 'password'" :append-icon="showNewPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showNewPwd = !showNewPwd" :rules="passwordRules" outlined ></v-text-field> <v-text-field v-model="passwordForm.confirmPassword" label="确认新密码" prepend-icon="mdi-lock-check" :type="showConfirmPwd ? 'text' : 'password'" :append-icon="showConfirmPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showConfirmPwd = !showConfirmPwd" :rules="confirmPasswordRules" outlined ></v-text-field> </v-form> </v-card-text> <v-card-actions> <v-spacer></v-spacer> <v-btn color="primary" :loading="pwdUpdating" @click="updatePassword"> 修改密码 </v-btn> </v-card-actions> </v-card> </v-col> </v-row> </v-container> </div> </template> <script> import { mapGetters, mapActions } from 'vuex' import { authApi } from '@/api/auth' import moment from 'moment' export default { name: 'Profile', data() { return { user: {}, editForm: { nickname: '', email: '', phone: '', signature: '' }, updating: false, updateSuccess: false, updateError: '', passwordForm: { oldPassword: '', newPassword: '', confirmPassword: '' }, pwdUpdating: false, pwdSuccess: false, pwdError: '', showOldPwd: false, showNewPwd: false, showConfirmPwd: false, emailRules: [ v => !v || /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) || '邮箱格式不正确' ], phoneRules: [ v => !v || /^1[3-9]\d{9}$/.test(v) || '手机号格式不正确' ], passwordRules: [ v => !!v || '请输入新密码', v => (v && v.length >= 6) || '密码长度不能小于6' ], confirmPasswordRules: [ v => !!v || '请确认密码', v => v === this.passwordForm.newPassword || '两次输入的密码不一致' ] } }, computed: { ...mapGetters('user', ['currentUser']) }, mounted() { this.user = { ...this.currentUser } this.editForm = { nickname: this.user.nickname || '', email: this.user.email || '', phone: this.user.phone || '', signature: this.user.signature || '' } }, methods: { ...mapActions('user', ['setUser', 'logout']), formatTime(time) { if (!time) return '' return moment(time).format('YYYY-MM-DD HH:mm:ss') }, async updateProfile() { this.updating = true this.updateError = '' this.updateSuccess = false try { // 更新资料接口(需要后端实现) // const res = await userApi.updateProfile(this.editForm) // if (res.code === 200) { // this.setUser({ ...this.currentUser, ...this.editForm }) // this.user = { ...this.user, ...this.editForm } // this.updateSuccess = true // } // 模拟成功 this.updateSuccess = true this.user = { ...this.user, ...this.editForm } this.setUser(this.user) } catch (error) { this.updateError = error.message || '更新失败' } finally { this.updating = false } }, async updatePassword() { if (!this.$refs.passwordForm.validate()) return this.pwdUpdating = true this.pwdError = '' this.pwdSuccess = false try { // 修改密码接口(需要后端实现) // const res = await userApi.changePassword({ // oldPassword: this.passwordForm.oldPassword, // newPassword: this.passwordForm.newPassword // }) // if (res.code === 200) { // this.pwdSuccess = true // setTimeout(() => { // this.logout() // this.$router.push('/login') // }, 2000) // } // 模拟成功 this.pwdSuccess = true setTimeout(() => { this.logout() this.$router.push('/login') }, 2000) } catch (error) { this.pwdError = error.message || '密码修改失败' } finally { this.pwdUpdating = false } } } } </script> <style scoped> .profile { padding-bottom: 40px; } .stat-number { font-size: 28px; font-weight: bold; color: #1976D2; } .stat-label { font-size: 14px; color: #666; margin-top: 4px; } </style>
// src/main.js import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import vuetify from './plugins/vuetify' import './plugins/quill-editor' Vue.config.productionTip = false new Vue({ router, store, vuetify, render: h => h(App) }).$mount('#app')
<!-- src/App.vue --> <template> <v-app> <Header /> <v-main> <router-view /> </v-main> </v-app> </template> <script> export default { name: 'App', components: { Header } } </script> <style> @import '~vuetify/dist/vuetify.min.css'; .v-main { background-color: #f5f5f5; } </style>
cd forum-frontend npm install
npm run serve
npm run build
运行后

本地输入地址
http://localhost:3000/login
页面如下
