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

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

  • 一、环境准备与项目创建
    • [1.1 安装 Node.js 和 npm](#1.1 安装 Node.js 和 npm)
    • [1.2 安装 Vue CLI](#1.2 安装 Vue CLI)
    • [1.3 创建项目](#1.3 创建项目)
    • [1.4 安装额外依赖](#1.4 安装额外依赖)
  • 二、项目架构
  • 三、配置文件
    • [3.1 vue.config.js](#3.1 vue.config.js)
  • 四、插件配置
    • [4.1 src/plugins/vuetify.js](#4.1 src/plugins/vuetify.js)
    • [4.2 src/plugins/quill-editor.js](#4.2 src/plugins/quill-editor.js)
  • [五、API 模块](#五、API 模块)
    • [5.1 src/api/index.js](#5.1 src/api/index.js)
    • [5.2 src/api/auth.js](#5.2 src/api/auth.js)
    • [5.3 src/api/post.js](#5.3 src/api/post.js)
    • [5.4 src/api/comment.js](#5.4 src/api/comment.js)
  • [六、Vuex Store](#六、Vuex Store)
    • [6.1 src/store/index.js](#6.1 src/store/index.js)
  • 七、路由配置
    • [7.1 src/router/index.js](#7.1 src/router/index.js)
  • 八、全局样式
    • [8.1 src/styles/global.scss](#8.1 src/styles/global.scss)
  • 九、组件
    • [9.1 src/components/Header.vue](#9.1 src/components/Header.vue)
    • [9.2 src/components/PostCard.vue](#9.2 src/components/PostCard.vue)
    • [9.3 src/components/CommentItem.vue](#9.3 src/components/CommentItem.vue)
  • 十、页面视图
    • [10.1 src/views/Login.vue](#10.1 src/views/Login.vue)
    • [10.2 src/views/Register.vue](#10.2 src/views/Register.vue)
    • [10.3 src/views/Home.vue](#10.3 src/views/Home.vue)
    • [10.4 src/views/PostCreate.vue](#10.4 src/views/PostCreate.vue)
    • [10.5 src/views/PostDetail.vue](#10.5 src/views/PostDetail.vue)
    • [10.6 src/views/Profile.vue](#10.6 src/views/Profile.vue)
  • 十一、应用入口文件
    • [11.1 src/main.js](#11.1 src/main.js)
    • [11.2 src/App.vue](#11.2 src/App.vue)
  • 十二、启动说明
    • [12.1 安装依赖](#12.1 安装依赖)
    • [12.2 开发环境运行](#12.2 开发环境运行)
    • [12.3 生产环境打包](#12.3 生产环境打包)
  • 源码地址

一、环境准备与项目创建

核心技术栈

技术 版本 说明
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 日期处理库

1.1 安装 Node.js 和 npm

bash 复制代码
# 检查是否已安装
node -v
npm -v

# 如果没有安装,去 https://nodejs.org 下载 LTS 版本

1.2 安装 Vue CLI

bash 复制代码
# 全局安装 Vue CLI
npm install -g @vue/cli

# 检查版本
vue --version

1.3 创建项目

在父目录下新建web项目

idea旧版本选择静态web

新版本选择vue

创建完成后目录

进入前端目录命令窗

1.4 安装额外依赖

bash 复制代码
cd forum-frontend

# 安装 axios
npm install axios

# 安装 quill-editor
npm install vue-quill-editor

# 安装 moment.js(日期格式化)
npm install moment

二、项目架构

bash 复制代码
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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
// 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
<!-- 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

javascript 复制代码
// 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

javascript 复制代码
<!-- 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 安装依赖

bash 复制代码
cd forum-frontend
npm install

12.2 开发环境运行

bash 复制代码
npm run serve

12.3 生产环境打包

bash 复制代码
npm run build

运行后

本地输入地址

http://localhost:3000/login

页面如下

源码地址

论坛系统前后端完整代码

相关推荐
ApjRvH3vg2 小时前
什么是Skills
前端
꧁꫞꯭零꯭点꯭꫞꧂2 小时前
JavaScript模块化规范
开发语言·前端·javascript
三万棵雪松2 小时前
【Linux 物联网网关主控系统-Web部分(四)】
linux·前端·物联网·嵌入式linux
摸鱼的春哥2 小时前
Agent教程22:AI大模型兼容,踩坑到崩溃
前端·javascript·后端
regret~2 小时前
【记录】前端创建
前端
深念Y2 小时前
前端实时通信技术:HTTP轮询、SSE、WebSocket、WebRTC
前端·websocket·网络协议·http·实时互动·轮询·实时通信
希望永不加班2 小时前
SpringBoot 多模块项目搭建:service/dao/web分层设计
java·前端·spring boot·后端·spring
小江的记录本2 小时前
【JEECG Boot】JEECG Boot 系统性知识体系全方位结构化总结
java·前端·spring boot·后端·python·spring·spring cloud
Mr.wangh2 小时前
Spring原理(Bean的生命周期)
java·前端·spring