网站搭建实操(十)前端搭建
- 一、环境准备与项目创建
-
- [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
运行后

本地输入地址
页面如下
