首页 > 前端开发 > 最新文章

网站搭建实操(十)前端搭建

CSDN博客 2026-04-20 12:05:01 人看过

一、环境准备与项目创建

核心技术栈


技术版本说明
Vue.js3.5.32前端核心框架(Vue 3)
Vue Router4.5.1Vue 官方路由管理器
Pinia2.3.1Vue 3 官方状态管理(替代 Vuex)
Axios1.5.0HTTP 请求库
Vuetify3.7.8Material Design UI 组件库
Vue Quill Editor3.0.6富文本编辑器
Moment.js2.30.1日期处理库


1.1 安装 Node.js 和 npm

# 检查是否已安装 node -v npm -v # 如果没有安装,去 https://nodejs.org 下载 LTS 版本

1.2 安装 Vue CLI

# 全局安装 Vue CLI npm install -g @vue/cli # 检查版本 vue --version

1.3 创建项目

在父目录下新建web项目
idea旧版本选择静态web
在这里插入图片描述
新版本选择vue
创建完成后目录
在这里插入图片描述
进入前端目录命令窗

在这里插入图片描述

1.4 安装额外依赖

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

三、配置文件

3.1 vue.config.js

// 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";`      }    }  } }

四、插件配置

4.1 src/plugins/vuetify.js

// 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'            }        }    } })

4.2 src/plugins/quill-  editor .js

// 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)

五、API 模块

5.1 src/api/index.js

// 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

5.2 src/api/auth.js

// 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')    } }

5.3 src/api/post.js

// 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`)    } }

5.4 src/api/comment.js

// 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}`)    } }

六、Vuex Store

6.1 src/store/index.js

// 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            }        }    } })

七、路由配置

7.1 src/router/index.js

// 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

八、全局样式

8.1 src/styles/global.scss

// 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; }

九、组件

9.1 src/components/Header.vue

<!-- 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>

9.2 src/components/PostCard.vue

<!-- 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>

9.3 src/components/CommentItem.vue

<!-- 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>

十、页面视图

10.1 src/views/Login.vue

<!-- 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>

10.2 src/views/Register.vue

<!-- 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>

10.3 src/views/Home.vue

<!-- 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>

10.4 src/views/PostCreate.vue

<!-- 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>

10.5 src/views/PostDetail.vue

<!-- 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>

10.6 src/views/Profile.vue

<!-- 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>

十一、应用入口文件

11.1 src/main.js

// 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')

11.2 src/  App              .vue

<!-- 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>

十二、启动说明

12.1 安装依赖

cd forum-frontend npm install

12.2 开发环境运行

npm run serve

12.3 生产环境打包

npm run build

运行后
在这里插入图片描述
本地输入地址
http://localhost:3000/login
页面如下
在这里插入图片描述

源码地址

论坛系统前后端完整代码

版权声明:倡导尊重与保护知识产权。未经许可,任何人不得复制、转载、或以其他方式使用本站《原创》内容,违者将追究其法律责任。本站文章内容,部分图片来源于网络,如有侵权,请联系我们修改或者删除处理。

编辑推荐

热门文章