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

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

一、环境准备与项目创建

核心技术栈

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

1.1 安装 Node.js 和 npm

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

1.2 安装 Vue CLI

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

1.3 创建项目

在父目录下新建web项目
idea旧版本选择静态web

在这里插入图片描述


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

在这里插入图片描述


进入前端目录命令窗

在这里插入图片描述

1.4 安装额外依赖

cd forum-frontend # 安装 axiosnpminstall axios # 安装 quill-editornpminstall vue-quill-editor # 安装 moment.js(日期格式化)npminstall moment 

二、项目架构

forum-frontend/ ├── public/ │ └── index.html ├── src/ │ ├── main.js │ ├── App.vue │ ├── plugins/ │ │ ├── vuetify.js │ │ └── quill-editor.js │ ├── api/ │ │ ├── index.js │ │ ├── auth.js │ │ ├── post.js │ │ └── comment.js │ ├── router/ │ │ └── index.js │ ├── store/ │ │ └── index.js │ ├── views/ │ │ ├── Login.vue │ │ ├── Register.vue │ │ ├── Home.vue │ │ ├── PostDetail.vue │ │ ├── PostCreate.vue │ │ └── Profile.vue │ ├── components/ │ │ ├── Header.vue │ │ ├── PostCard.vue │ │ └── CommentItem.vue │ └── styles/ │ └── global.scss ├── vue.config.js └── package.json 

三、配置文件

3.1 vue.config.js

// vue.config.js module.exports ={devServer:{port:3000,proxy:{'/api':{target:'http://localhost:8080',changeOrigin:true,pathRewrite:{'^/api':'/api'}}}},css:{loaderOptions:{sass:{additionalData:`@import "@/styles/global.scss";`}}}}

四、插件配置

4.1 src/plugins/vuetify.js

// src/plugins/vuetify.jsimport Vue from'vue'import Vuetify from'vuetify/lib'import'vuetify/dist/vuetify.min.css' Vue.use(Vuetify)exportdefaultnewVuetify({theme:{themes:{light:{primary:'#1976D2',secondary:'#424242',accent:'#82B1FF',error:'#FF5252',info:'#2196F3',success:'#4CAF50',warning:'#FFC107'}}}})

4.2 src/plugins/quill-editor.js

// src/plugins/quill-editor.jsimport Vue from'vue'import VueQuillEditor from'vue-quill-editor'import'quill/dist/quill.core.css'import'quill/dist/quill.snow.css'import'quill/dist/quill.bubble.css' Vue.use(VueQuillEditor)

五、API 模块

5.1 src/api/index.js

// src/api/index.jsimport 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(newError(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)})exportdefault service 

5.2 src/api/auth.js

// src/api/auth.jsimport request from'./index'exportconst authApi ={// 登录login(data){return request.post('/auth/login', data)},// 注册register(data){return request.post('/auth/register', data)},// 获取当前用户getCurrentUser(){return request.get('/auth/current')}}

5.3 src/api/post.js

// src/api/post.jsimport request from'./index'exportconst postApi ={// 发布帖子create(data){return request.post('/posts', data)},// 获取帖子详情getDetail(id){return request.get(`/posts/${id}`)},// 分页获取帖子列表getPage(params){return request.get('/posts/page',{ params })},// 更新帖子update(id, data){return request.put(`/posts/${id}`, data)},// 删除帖子delete(id){return request.delete(`/posts/${id}`)},// 置顶帖子stick(id){return request.put(`/posts/${id}/stick`)},// 设为精华essence(id){return request.put(`/posts/${id}/essence`)}}

5.4 src/api/comment.js

// src/api/comment.jsimport request from'./index'exportconst commentApi ={// 发布评论create(data){return request.post('/comments', data)},// 获取帖子评论列表getByPostId(postId, params){return request.get(`/comments/post/${postId}`,{ params })},// 删除评论delete(id){return request.delete(`/comments/${id}`)}}

六、Vuex Store

6.1 src/store/index.js

// src/store/index.jsimport Vue from'vue'import Vuex from'vuex' Vue.use(Vuex)exportdefaultnewVuex.Store({modules:{user:{namespaced:true,state:{user:null,token: localStorage.getItem('token')},mutations:{SET_USER(state, user){ state.user = user if(user && user.token){ state.token = user.token localStorage.setItem('token', user.token) localStorage.setItem('user',JSON.stringify(user))}},LOGOUT(state){ state.user =null state.token =null localStorage.removeItem('token') localStorage.removeItem('user')}},actions:{setUser({ commit }, user){commit('SET_USER', user)},logout({ commit }){commit('LOGOUT')},loadFromStorage({ commit }){const token = localStorage.getItem('token')const user = localStorage.getItem('user')if(token && user){commit('SET_USER',JSON.parse(user))}}},getters:{isLoggedIn:state=>!!state.token,currentUser:state=> state.user }}}})

七、路由配置

7.1 src/router/index.js

// src/router/index.jsimport 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 =newVueRouter({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()}})exportdefault router 

八、全局样式

8.1 src/styles/global.scss

// src/styles/global.scss*{margin:0;padding:0; box-sizing: border-box;} body { font-family:'Roboto','Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;}.main-content { max-width: 1200px;margin: 80px auto 20px;padding:0 20px; min-height:calc(100vh - 100px);}.markdown-body { font-size: 16px; line-height:1.6; word-wrap:break-word;}.markdown-body pre {background: #f6f8fa;padding: 16px; border-radius: 6px; overflow-x: auto;}.markdown-body code {background: #f6f8fa;padding: 2px 6px; border-radius: 4px; font-family:'Courier New', monospace;}

九、组件

9.1 src/components/Header.vue

<!-- src/components/Header.vue --><template><v-app-bar app color="primary" dark><v-app-bar-nav-icon @click="drawer = !drawer"class="d-md-none"></v-app-bar-nav-icon><v-toolbar-title @click="$router.push('/')" style="cursor: pointer"> 📝 论坛系统 </v-toolbar-title><v-spacer></v-spacer><!--PC端导航 --><div class="d-none d-md-flex align-center"><v-btn text @click="$router.push('/')">首页</v-btn><template v-if="isLoggedIn"><v-btn text @click="$router.push('/post/create')">发布帖子</v-btn><v-menu offset-y><template v-slot:activator="{ on, attrs }"><v-btn text v-bind="attrs" v-on="on"><v-avatar size="32"class="mr-2"><v-icon>mdi-account-circle</v-icon></v-avatar>{{ currentUser.nickname || currentUser.username }}<v-icon right>mdi-chevron-down</v-icon></v-btn></template><v-list><v-list-item @click="$router.push('/profile')"><v-list-item-title>个人中心</v-list-item-title></v-list-item><v-list-item @click="handleLogout"><v-list-item-title>退出登录</v-list-item-title></v-list-item></v-list></v-menu></template><template v-else><v-btn text @click="$router.push('/login')">登录</v-btn><v-btn text @click="$router.push('/register')">注册</v-btn></template></div><!-- 移动端抽屉菜单 --><v-navigation-drawer v-model="drawer" temporary absolute><v-list nav><v-list-item @click="navigate('/')"><v-list-item-icon><v-icon>mdi-home</v-icon></v-list-item-icon><v-list-item-title>首页</v-list-item-title></v-list-item><v-list-item v-if="isLoggedIn" @click="navigate('/post/create')"><v-list-item-icon><v-icon>mdi-pencil</v-icon></v-list-item-icon><v-list-item-title>发布帖子</v-list-item-title></v-list-item><v-list-item v-if="isLoggedIn" @click="navigate('/profile')"><v-list-item-icon><v-icon>mdi-account</v-icon></v-list-item-icon><v-list-item-title>个人中心</v-list-item-title></v-list-item><v-list-item v-if="!isLoggedIn" @click="navigate('/login')"><v-list-item-icon><v-icon>mdi-login</v-icon></v-list-item-icon><v-list-item-title>登录</v-list-item-title></v-list-item><v-list-item v-if="!isLoggedIn" @click="navigate('/register')"><v-list-item-icon><v-icon>mdi-account-plus</v-icon></v-list-item-icon><v-list-item-title>注册</v-list-item-title></v-list-item><v-list-item v-if="isLoggedIn" @click="handleLogout"><v-list-item-icon><v-icon>mdi-logout</v-icon></v-list-item-icon><v-list-item-title>退出登录</v-list-item-title></v-list-item></v-list></v-navigation-drawer></v-app-bar></template><script>import{ mapGetters, mapActions }from'vuex'exportdefault{name:'Header',data(){return{drawer:false}},computed:{...mapGetters('user',['isLoggedIn','currentUser'])},methods:{...mapActions('user',['logout']),navigate(path){this.drawer =falsethis.$router.push(path)},handleLogout(){this.logout()this.$router.push('/login')}}}</script><style scoped>.v-toolbar-title {cursor: pointer;}</style>

9.2 src/components/PostCard.vue

<!-- src/components/PostCard.vue --><template><v-card class="post-card mb-4" elevation="2" hover @click="$emit('click')"><v-card-title class="pb-2"><div class="d-flex align-center"><v-avatar size="40"class="mr-3"><v-icon large>mdi-account-circle</v-icon></v-avatar><div><div class="subtitle-2">{{ post.nickname ||'用户'+ post.userId }}</div><div class="caption grey--text">{{formatTime(post.createdTime)}}</div></div><v-spacer></v-spacer><div><v-chip v-if="post.type === 3" small color="red" text-color="white">置顶</v-chip><v-chip v-else-if="post.type === 2" small color="orange" text-color="white">精华</v-chip></div></div></v-card-title><v-card-title class="pt-0"><div class="post-title">{{ post.title }}</div></v-card-title><v-card-text><div class="post-summary">{{getSummary(post.content)}}</div></v-card-text><v-card-actions><v-chip small outlined><v-icon left small>mdi-eye</v-icon>{{ post.viewCount ||0}}</v-chip><v-chip small outlined class="ml-2"><v-icon left small>mdi-message</v-icon>{{ post.replyCount ||0}}</v-chip><v-chip small outlined class="ml-2"><v-icon left small>mdi-thumb-up</v-icon>{{ post.likeCount ||0}}</v-chip><v-spacer></v-spacer><v-chip small color="grey lighten-2">{{ post.categoryName ||'综合'}}</v-chip></v-card-actions></v-card></template><script>import moment from'moment'exportdefault{name:'PostCard',props:{post:{type: Object,required:true}},methods:{formatTime(time){if(!time)return''returnmoment(time).fromNow()},getSummary(content){if(!content)return''const text = content.replace(/<[^>]*>/g,'')return text.length >150? text.substring(0,150)+'...': text }}}</script><style scoped>.post-card {cursor: pointer;transition: transform 0.2s;}.post-card:hover {transform:translateY(-2px);}.post-title { font-size: 18px; font-weight:500;color: #333;}.post-summary {color: #666; line-height:1.6;}</style>

9.3 src/components/CommentItem.vue

<!-- src/components/CommentItem.vue --><template><v-card class="comment-item mb-3" elevation="1"><v-card-text><div class="d-flex align-center mb-3"><v-avatar size="32"class="mr-2"><v-icon small>mdi-account-circle</v-icon></v-avatar><div><div class="subtitle-2">{{ comment.nickname ||'用户'+ comment.userId }}</div><div class="caption grey--text">{{formatTime(comment.createdTime)}}</div></div><v-spacer></v-spacer><v-btn icon small @click="$emit('reply')" v-if="showReply"><v-icon small>mdi-reply</v-icon></v-btn></div><div class="comment-content" v-html="comment.content"></div><div class="d-flex align-center mt-3"><v-btn icon small @click="handleLike"><v-icon small :color="isLiked ? 'red' : ''">mdi-heart</v-icon></v-btn><span class="caption ml-1">{{ comment.likeCount ||0}}</span></div><!-- 子评论 --><div v-if="comment.children && comment.children.length"class="child-comments mt-3"><comment-item v-for="child in comment.children":key="child.id":comment="child":show-reply="false" @reply="$emit('reply', child)"/></div></v-card-text></v-card></template><script>import moment from'moment'exportdefault{name:'CommentItem',props:{comment:{type: Object,required:true},showReply:{type: Boolean,default:true}},data(){return{isLiked:false}},methods:{formatTime(time){if(!time)return''returnmoment(time).fromNow()},handleLike(){this.isLiked =!this.isLiked this.$emit('like',this.comment.id)}}}</script><style scoped>.comment-item {background: #fafafa;}.comment-content { font-size: 14px; line-height:1.5;color: #333;}.child-comments { margin-left: 40px; padding-left: 20px; border-left: 2px solid #e0e0e0;}</style>

十、页面视图

10.1 src/views/Login.vue

<!-- src/views/Login.vue --><template><v-container fluid fill-height class="login-container"><v-row align="center" justify="center"><v-col cols="12" sm="8" md="4"><v-card class="login-card elevation-12"><v-card-title class="justify-center"><h2 class="primary--text">论坛系统</h2></v-card-title><v-card-subtitle class="text-center">欢迎回来,请登录您的账号</v-card-subtitle><v-card-text><v-alert v-if="errorMessage" type="error" dense dismissible>{{ errorMessage }}</v-alert><v-form ref="form" v-model="valid"><v-text-field v-model="form.username" label="用户名" prepend-icon="mdi-account":rules="[v => !!v || '用户名不能为空']" outlined ></v-text-field><v-text-field v-model="form.password" label="密码" prepend-icon="mdi-lock":type="showPassword ? 'text' : 'password'":append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showPassword = !showPassword":rules="[v => !!v || '密码不能为空']" outlined @keyup.enter="handleLogin"></v-text-field></v-form></v-card-text><v-card-actions class="px-4 pb-4"><v-btn color="primary" block large :loading="loading" @click="handleLogin"> 登录 </v-btn></v-card-actions><v-card-text class="text-center"> 还没有账号? <router-link to="/register">立即注册</router-link></v-card-text></v-card></v-col></v-row></v-container></template><script>import{ mapActions }from'vuex'import{ authApi }from'@/api/auth'exportdefault{name:'Login',data(){return{valid:false,showPassword:false,loading:false,errorMessage:'',form:{username:'',password:''}}},methods:{...mapActions('user',['setUser']),asynchandleLogin(){if(!this.$refs.form.validate())returnthis.loading =truethis.errorMessage =''try{const res =await authApi.login(this.form)if(res.code ===200){this.setUser(res.data)this.$router.push('/')}}catch(error){this.errorMessage = error.message ||'登录失败,请稍后重试'}finally{this.loading =false}}}}</script><style scoped>.login-container {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;}.login-card { border-radius: 16px;}</style>

10.2 src/views/Register.vue

<!-- src/views/Register.vue --><template><v-container fluid fill-height class="register-container"><v-row align="center" justify="center"><v-col cols="12" sm="8" md="5"><v-card class="register-card elevation-12"><v-card-title class="justify-center"><h2 class="primary--text">注册新账号</h2></v-card-title><v-card-subtitle class="text-center">加入论坛,分享知识</v-card-subtitle><v-card-text><v-alert v-if="errorMessage" type="error" dense dismissible>{{ errorMessage }}</v-alert><v-alert v-if="successMessage" type="success" dense>{{ successMessage }}</v-alert><v-form ref="form" v-model="valid"><v-text-field v-model="form.username" label="用户名" prepend-icon="mdi-account":rules="usernameRules" outlined ></v-text-field><v-text-field v-model="form.email" label="邮箱" prepend-icon="mdi-email":rules="emailRules" outlined ></v-text-field><v-text-field v-model="form.phone" label="手机号" prepend-icon="mdi-phone":rules="phoneRules" outlined ></v-text-field><v-text-field v-model="form.password" label="密码" prepend-icon="mdi-lock":type="showPassword ? 'text' : 'password'":append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showPassword = !showPassword":rules="passwordRules" outlined ></v-text-field><v-text-field v-model="form.confirmPassword" label="确认密码" prepend-icon="mdi-lock-check":type="showConfirmPassword ? 'text' : 'password'":append-icon="showConfirmPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showConfirmPassword = !showConfirmPassword":rules="confirmPasswordRules" outlined ></v-text-field><v-text-field v-model="form.nickname" label="昵称" prepend-icon="mdi-card-account-details" outlined ></v-text-field></v-form></v-card-text><v-card-actions class="px-4 pb-4"><v-btn color="primary" block large :loading="loading" @click="handleRegister"> 注册 </v-btn></v-card-actions><v-card-text class="text-center"> 已有账号? <router-link to="/login">立即登录</router-link></v-card-text></v-card></v-col></v-row></v-container></template><script>import{ authApi }from'@/api/auth'exportdefault{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:{asynchandleRegister(){if(!this.$refs.form.validate())returnthis.loading =truethis.errorMessage =''try{const res =await authApi.register(this.form)if(res.code ===200){this.successMessage ='注册成功!即将跳转到登录页...'setTimeout(()=>{this.$router.push('/login')},1500)}}catch(error){this.errorMessage = error.message ||'注册失败,请稍后重试'}finally{this.loading =false}}}}</script><style scoped>.register-container {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;}.register-card { border-radius: 16px;}</style>

10.3 src/views/Home.vue

<!-- src/views/Home.vue --><template><div class="home"><v-container><!-- 欢迎横幅 --><v-row><v-col cols="12"><v-card color="primary" dark class="mb-6 welcome-card"><v-card-title class="headline"> 欢迎来到论坛,{{ currentUser.nickname || currentUser.username }}! </v-card-title><v-card-subtitle> 分享知识,交流思想,结识朋友 </v-card-subtitle></v-card></v-col></v-row><!-- 操作栏 --><v-row><v-col cols="12"><div class="d-flex justify-space-between align-center mb-4"><h3>最新帖子</h3><v-btn color="primary" to="/post/create"><v-icon left>mdi-pencil</v-icon> 发布新帖 </v-btn></div></v-col></v-row><!-- 帖子列表 --><v-row><v-col cols="12"><v-progress-circular v-if="loading" indeterminate color="primary"class="d-block mx-auto"></v-progress-circular><PostCard v-for="post in posts":key="post.id":post="post" @click="goToDetail(post.id)"/><v-card v-if="!loading && posts.length === 0"class="text-center py-8"><v-icon size="64" color="grey lighten-1">mdi-forum-outline</v-icon><div class="mt-2 grey--text">暂无帖子,快来发布第一个吧!</div></v-card></v-col></v-row><!-- 分页 --><v-row v-if="total > pageSize"><v-col cols="12"><div class="d-flex justify-center mt-4"><v-pagination v-model="pageNum":length="totalPages":total-visible="7" @input="loadPosts"></v-pagination></div></v-col></v-row></v-container></div></template><script>import{ mapGetters }from'vuex'import{ postApi }from'@/api/post'import PostCard from'@/components/PostCard.vue'exportdefault{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:{asyncloadPosts(){this.loading =truetry{const res =await postApi.getPage({pageNum:this.pageNum,pageSize:this.pageSize })if(res.code ===200){this.posts = res.data.records ||[]this.total = res.data.total ||0}}catch(error){ console.error('加载帖子失败', error)}finally{this.loading =false}},goToDetail(id){this.$router.push(`/post/${id}`)}}}</script><style scoped>.welcome-card {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)!important;}</style>

10.4 src/views/PostCreate.vue

<!-- src/views/PostCreate.vue --><template><div class="post-create"><v-container><v-row><v-col cols="12"><v-card><v-card-title class="primary white--text"><v-icon dark left>mdi-pencil</v-icon> 发布新帖子 </v-card-title><v-card-text class="pa-6"><v-alert v-if="errorMessage" type="error" dense dismissible class="mb-4">{{ errorMessage }}</v-alert><v-form ref="form" v-model="valid"><!-- 版块选择 --><v-select v-model="form.categoryId":items="categories" item-text="name" item-value="id" label="选择版块" prepend-icon="mdi-folder":rules="[v => !!v || '请选择版块']" outlined ></v-select><!-- 帖子标题 --><v-text-field v-model="form.title" label="帖子标题" prepend-icon="mdi-format-title":rules="titleRules" counter="100" outlined ></v-text-field><!-- 帖子类型 --><v-radio-group v-model="form.type" row><v-radio label="普通帖":value="1"></v-radio><v-radio label="精华帖":value="2"></v-radio><v-radio label="置顶帖":value="3"></v-radio></v-radio-group><!-- 标签输入 --><v-combobox v-model="form.tags" label="标签" prepend-icon="mdi-tag" multiple small-chips deletable-chips outlined placeholder="输入标签后按回车添加"></v-combobox><!-- 富文本编辑器 --><div class="mb-4"><label class="v-label theme--light mb-2">帖子内容</label><quill-editor v-model="form.content" ref="myQuillEditor":options="editorOption" @change="onEditorChange"></quill-editor></div><!-- Markdown 内容 --><v-textarea v-model="form.contentMd" label="Markdown内容(可选)" prepend-icon="mdi-language-markdown" rows="5" outlined hint="支持Markdown格式,优先级高于富文本"></v-textarea></v-form></v-card-text><v-card-actions class="pa-4"><v-spacer></v-spacer><v-btn @click="cancel" outlined>取消</v-btn><v-btn color="primary":loading="submitting":disabled="!valid" @click="handleSubmit"> 发布帖子 </v-btn></v-card-actions></v-card></v-col></v-row></v-container></div></template><script>import{ postApi }from'@/api/post'exportdefault{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 },asynchandleSubmit(){if(!this.$refs.form.validate())returnthis.submitting =truethis.errorMessage =''try{// 将标签数组转换为逗号分隔的字符串const submitData ={...this.form,tags:this.form.tags.join(',')}const res =await postApi.create(submitData)if(res.code ===200){this.$router.push(`/post/${res.data.id}`)}}catch(error){this.errorMessage = error.message ||'发布失败,请稍后重试'}finally{this.submitting =false}},cancel(){this.$router.go(-1)}}}</script><style scoped>.post-create { padding-bottom: 40px;}.ql-editor { min-height: 300px;}</style>

10.5 src/views/PostDetail.vue

<!-- src/views/PostDetail.vue --><template><div class="post-detail"><v-container><v-row><v-col cols="12"><!-- 加载中 --><div v-if="loading"class="text-center py-8"><v-progress-circular indeterminate color="primary" size="64"></v-progress-circular></div><!-- 帖子内容 --><template v-else><v-card><!-- 帖子头部 --><v-card-title class="post-title">{{ post.title }}<v-spacer></v-spacer><v-chip v-if="post.type === 3" color="red" text-color="white" small>置顶</v-chip><v-chip v-else-if="post.type === 2" color="orange" text-color="white" small>精华</v-chip></v-card-title><v-card-subtitle><div class="d-flex align-center"><v-avatar size="40"class="mr-3"><v-icon large>mdi-account-circle</v-icon></v-avatar><div><div class="subtitle-1">{{ post.nickname ||'用户'+ post.userId }}</div><div class="caption grey--text"> 发布于 {{formatTime(post.createdTime)}}<span v-if="post.updatedTime !== post.createdTime"> · 最后编辑于 {{formatTime(post.updatedTime)}}</span></div></div><v-spacer></v-spacer><div class="stats"><v-chip small outlined class="mr-2"><v-icon left small>mdi-eye</v-icon>{{ post.viewCount ||0}}</v-chip><v-chip small outlined><v-icon left small>mdi-message</v-icon>{{ post.replyCount ||0}}</v-chip></div></div></v-card-subtitle><!-- 操作按钮 --><v-card-actions v-if="canEdit"><v-btn small text color="primary" @click="editPost"><v-icon left small>mdi-pencil</v-icon> 编辑 </v-btn><v-btn small text color="error" @click="deletePost"><v-icon left small>mdi-delete</v-icon> 删除 </v-btn></v-card-actions><v-divider></v-divider><!-- 帖子内容 --><v-card-text><div class="post-content" v-html="post.content"></div><!-- 标签 --><div v-if="post.tags && post.tags.length"class="mt-4"><v-chip v-for="tag in post.tags.split(',')":key="tag" small class="mr-2" color="grey lighten-2"> #{{ tag }}</v-chip></div></v-card-text><!-- 互动按钮 --><v-card-actions><v-btn text :color="isLiked ? 'red' : ''" @click="handleLike"><v-icon left>mdi-heart</v-icon>{{ post.likeCount ||0}}</v-btn><v-btn text :color="isCollected ? 'amber' : ''" @click="handleCollect"><v-icon left>mdi-star</v-icon>{{ post.collectCount ||0}}</v-btn><v-btn text @click="scrollToComment"><v-icon left>mdi-message</v-icon> 回复 </v-btn></v-card-actions></v-card><!-- 评论区域 --><v-card class="mt-4" ref="commentSection"><v-card-title> 评论({{ totalComments }}) </v-card-title><v-divider></v-divider><!-- 发表评论 --><v-card-text><v-form ref="commentForm"><v-textarea v-model="commentContent" label="发表你的评论..." rows="3"outlined:rules="[v => !!v || '评论内容不能为空']"></v-textarea><v-btn color="primary":loading="commentSubmitting" @click="submitComment"> 发表评论 </v-btn></v-form></v-card-text><v-divider></v-divider><!-- 评论列表 --><v-card-text v-if="commentsLoading"><div class="text-center py-4"><v-progress-circular indeterminate color="primary"></v-progress-circular></div></v-card-text><v-card-text v-else><CommentItem v-for="comment in comments":key="comment.id":comment="comment" @reply="replyToComment" @like="likeComment"/><div v-if="comments.length === 0"class="text-center py-8 grey--text"> 暂无评论,快来抢沙发吧! </div></v-card-text><!-- 评论分页 --><v-card-actions v-if="totalComments > commentPageSize"><v-spacer></v-spacer><v-pagination v-model="commentPageNum":length="commentTotalPages":total-visible="5" @input="loadComments"></v-pagination></v-card-actions></v-card></template></v-col></v-row></v-container></div></template><script>import{ mapGetters }from'vuex'import{ postApi }from'@/api/post'import{ commentApi }from'@/api/comment'import CommentItem from'@/components/CommentItem.vue'import moment from'moment'exportdefault{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(){returnthis.$route.params.id },canEdit(){returnthis.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''returnmoment(time).format('YYYY-MM-DD HH:mm:ss')},asyncloadPost(){this.loading =truetry{const res =await postApi.getDetail(this.postId)if(res.code ===200){this.post = res.data }}catch(error){ console.error('加载帖子失败', error)}finally{this.loading =false}},asyncloadComments(){this.commentsLoading =truetry{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}},asyncsubmitComment(){if(!this.commentContent.trim()){this.$refs.commentForm.validate()return}this.commentSubmitting =truetry{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 =nullthis.commentPageNum =1awaitthis.loadComments()awaitthis.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'})},asynchandleLike(){// 点赞功能(需要后端实现)this.isLiked =!this.isLiked if(this.isLiked){this.post.likeCount =(this.post.likeCount ||0)+1}else{this.post.likeCount =(this.post.likeCount ||0)-1}},asynchandleCollect(){// 收藏功能(需要后端实现)this.isCollected =!this.isCollected if(this.isCollected){this.post.collectCount =(this.post.collectCount ||0)+1}else{this.post.collectCount =(this.post.collectCount ||0)-1}},asynclikeComment(commentId){// 评论点赞(需要后端实现) console.log('点赞评论', commentId)},editPost(){this.$router.push(`/post/${this.postId}/edit`)},asyncdeletePost(){const confirm =awaitthis.$confirm('确定要删除这篇帖子吗?','提示',{confirmButtonText:'确定',cancelButtonText:'取消',type:'warning'}).catch(()=>false)if(confirm){try{const res =await postApi.delete(this.postId)if(res.code ===200){this.$router.push('/')}}catch(error){ console.error('删除失败', error)}}},scrollToComment(){this.$refs.commentSection.scrollIntoView({behavior:'smooth'})}}}</script><style scoped>.post-detail { padding-bottom: 40px;}.post-title { font-size: 24px; font-weight: bold; flex-wrap: wrap;}.post-content { font-size: 16px; line-height:1.8;}.post-content img { max-width:100%;height: auto;}.stats {display: flex;gap: 8px;}</style>

10.6 src/views/Profile.vue

<!-- src/views/Profile.vue --><template><div class="profile"><v-container><v-row><v-col cols="12" md="4"><!-- 个人信息卡片 --><v-card><v-card-title class="primary white--text"><v-icon dark left>mdi-account-circle</v-icon> 个人资料 </v-card-title><v-card-text class="text-center py-6"><v-avatar size="120"class="mb-4"><v-icon size="120">mdi-account-circle</v-icon></v-avatar><h3>{{ user.nickname || user.username }}</h3><div class="grey--text">@{{ user.username }}</div></v-card-text><v-divider></v-divider><v-list dense><v-list-item><v-list-item-icon><v-icon>mdi-email</v-icon></v-list-item-icon><v-list-item-content><v-list-item-title>邮箱</v-list-item-title><v-list-item-subtitle>{{ user.email ||'未设置'}}</v-list-item-subtitle></v-list-item-content></v-list-item><v-list-item><v-list-item-icon><v-icon>mdi-phone</v-icon></v-list-item-icon><v-list-item-content><v-list-item-title>手机号</v-list-item-title><v-list-item-subtitle>{{ user.phone ||'未设置'}}</v-list-item-subtitle></v-list-item-content></v-list-item><v-list-item><v-list-item-icon><v-icon>mdi-calendar</v-icon></v-list-item-icon><v-list-item-content><v-list-item-title>注册时间</v-list-item-title><v-list-item-subtitle>{{formatTime(user.createdTime)}}</v-list-item-subtitle></v-list-item-content></v-list-item></v-list></v-card></v-col><v-col cols="12" md="8"><!-- 统计数据卡片 --><v-row><v-col cols="6" sm="3"><v-card class="text-center pa-4"><div class="stat-number">{{ user.postCount ||0}}</div><div class="stat-label">帖子</div></v-card></v-col><v-col cols="6" sm="3"><v-card class="text-center pa-4"><div class="stat-number">{{ user.replyCount ||0}}</div><div class="stat-label">回复</div></v-card></v-col><v-col cols="6" sm="3"><v-card class="text-center pa-4"><div class="stat-number">{{ user.followerCount ||0}}</div><div class="stat-label">粉丝</div></v-card></v-col><v-col cols="6" sm="3"><v-card class="text-center pa-4"><div class="stat-number">{{ user.followingCount ||0}}</div><div class="stat-label">关注</div></v-card></v-col></v-row><!-- 编辑资料表单 --><v-card class="mt-4"><v-card-title><v-icon left>mdi-account-edit</v-icon> 编辑资料 </v-card-title><v-divider></v-divider><v-card-text><v-alert v-if="updateSuccess" type="success" dense dismissible> 资料更新成功! </v-alert><v-alert v-if="updateError" type="error" dense dismissible>{{ updateError }}</v-alert><v-form ref="profileForm"><v-text-field v-model="editForm.nickname" label="昵称" prepend-icon="mdi-card-account-details" outlined ></v-text-field><v-text-field v-model="editForm.email" label="邮箱" prepend-icon="mdi-email":rules="emailRules" outlined ></v-text-field><v-text-field v-model="editForm.phone" label="手机号" prepend-icon="mdi-phone":rules="phoneRules" outlined ></v-text-field><v-textarea v-model="editForm.signature" label="个性签名" prepend-icon="mdi-format-quote-open" rows="3" outlined counter="200"></v-textarea></v-form></v-card-text><v-card-actions><v-spacer></v-spacer><v-btn color="primary":loading="updating" @click="updateProfile"> 保存修改 </v-btn></v-card-actions></v-card><!-- 修改密码 --><v-card class="mt-4"><v-card-title><v-icon left>mdi-lock-reset</v-icon> 修改密码 </v-card-title><v-divider></v-divider><v-card-text><v-alert v-if="pwdSuccess" type="success" dense dismissible> 密码修改成功,请重新登录! </v-alert><v-alert v-if="pwdError" type="error" dense dismissible>{{ pwdError }}</v-alert><v-form ref="passwordForm"><v-text-field v-model="passwordForm.oldPassword" label="当前密码" prepend-icon="mdi-lock":type="showOldPwd ? 'text' : 'password'":append-icon="showOldPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showOldPwd = !showOldPwd":rules="[v => !!v || '请输入当前密码']" outlined ></v-text-field><v-text-field v-model="passwordForm.newPassword" label="新密码" prepend-icon="mdi-lock-plus":type="showNewPwd ? 'text' : 'password'":append-icon="showNewPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showNewPwd = !showNewPwd":rules="passwordRules" outlined ></v-text-field><v-text-field v-model="passwordForm.confirmPassword" label="确认新密码" prepend-icon="mdi-lock-check":type="showConfirmPwd ? 'text' : 'password'":append-icon="showConfirmPwd ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showConfirmPwd = !showConfirmPwd":rules="confirmPasswordRules" outlined ></v-text-field></v-form></v-card-text><v-card-actions><v-spacer></v-spacer><v-btn color="primary":loading="pwdUpdating" @click="updatePassword"> 修改密码 </v-btn></v-card-actions></v-card></v-col></v-row></v-container></div></template><script>import{ mapGetters, mapActions }from'vuex'import{ authApi }from'@/api/auth'import moment from'moment'exportdefault{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''returnmoment(time).format('YYYY-MM-DD HH:mm:ss')},asyncupdateProfile(){this.updating =truethis.updateError =''this.updateSuccess =falsetry{// 更新资料接口(需要后端实现)// 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 =truethis.user ={...this.user,...this.editForm }this.setUser(this.user)}catch(error){this.updateError = error.message ||'更新失败'}finally{this.updating =false}},asyncupdatePassword(){if(!this.$refs.passwordForm.validate())returnthis.pwdUpdating =truethis.pwdError =''this.pwdSuccess =falsetry{// 修改密码接口(需要后端实现)// 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 =truesetTimeout(()=>{this.logout()this.$router.push('/login')},2000)}catch(error){this.pwdError = error.message ||'密码修改失败'}finally{this.pwdUpdating =false}}}}</script><style scoped>.profile { padding-bottom: 40px;}.stat-number { font-size: 28px; font-weight: bold;color: #1976D2;}.stat-label { font-size: 14px;color: #666; margin-top: 4px;}</style>

十一、应用入口文件

11.1 src/main.js

// src/main.jsimport 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 =falsenewVue({ router, store, vuetify,render:h=>h(App)}).$mount('#app')

11.2 src/App.vue

<!-- src/App.vue --><template><v-app><Header /><v-main><router-view /></v-main></v-app></template><script>exportdefault{name:'App',components:{ Header }}</script><style> @import'~vuetify/dist/vuetify.min.css';.v-main { background-color: #f5f5f5;}</style>

十二、启动说明

12.1 安装依赖

cd forum-frontend npminstall

12.2 开发环境运行

npm run serve 

12.3 生产环境打包

npm run build 

运行后

在这里插入图片描述


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

在这里插入图片描述

源码地址

论坛系统前后端完整代码

Read more

32款“Claw系”国产AI神器全收录 + 官方下载链接,收藏这一篇就够了!

【腾讯系】7款 # 产品名称 一句话简介 官网/下载 1 腾讯 WorkBuddy 全场景AI工作助手 https://pan.quark.cn/s/3937acbfc858 2 腾讯 QClaw 通用型AI智能体框架 https://pan.quark.cn/s/3c59da0b9220 3 腾讯龙虾管家 企业级AI运维管理 待核实 4 腾讯云保安 云安全AI防护智能体 cloud.tencent.com 5 腾讯乐享知识库·龙虾版 企业知识库AI增强版 待核实 6 腾讯企点Claw 智能客服与营销AI qidian.qq.com 7 腾讯会议Claw 会议纪要+

KimiClaw/MaxClaw/NullClaw/OpenFang/ZeroClaw/PicoClaw/TinyClaw/Miclaw/ArkClaw等18大小龙虾AI Agent框架技术选型全解析

KimiClaw/MaxClaw/NullClaw/OpenFang/ZeroClaw/PicoClaw/TinyClaw/Miclaw/ArkClaw等18大小龙虾AI Agent框架技术选型全解析

OpenClaw登顶GitHub全球TOP1!26万星超越React/Linux,KimiClaw/MaxClaw/NullClaw/OpenFang/EasyClaw/CoPaw/OpenClawChinese/LobsterAI/ClawPhone/Nanobot/NanoClaw/IronClaw/ZeroClaw/PicoClaw/TinyClaw/Miclaw/ArkClaw等18大AI Agent框架技术选型全解析 文章标签:#OpenClaw #GitHub星标第一 #KimiClaw #MaxClaw #NullClaw #OpenFang #EasyClaw #CoPaw #OpenClawChinese #LobsterAI #ClawPhone #Nanobot #NanoClaw #IronClaw #ZeroClaw #PicoClaw #TinyClaw #Miclaw #ArkClaw #AIAgent框架 #技术选型 #GitHub开源 🔥 历史性时刻:2026年3月,OpenClaw以26万+ GitHub Stars正式超越React(24.

QClaw 上手指南:我用了一周龙虾,感觉自己白用了两年 AI

QClaw 上手指南:我用了一周龙虾,感觉自己白用了两年 AI

欢迎来到我的博客,代码的世界里,每一行都是一个故事 🎏:你只管努力,剩下的交给时间 🏠 :小破站 QClaw 上手指南:我用了一周龙虾,感觉自己白用了两年 AI * 先说清楚:OpenClaw 是什么,龙虾又是怎么来的 * 第一次打开:它先问你是谁 * 微信直联:手机变成了 AI 的遥控器 * 接入自定义模型:你的 API 你做主 * Skills 插件:能力边界一直在扩 * 角色系统:不是换个语气,是换个工作模式 * 定时任务:让 AI 主动替你干活 * 它是怎么「记住你」的 * 本地跑意味着什么 * 适合什么人用 * 最后 如果你最近在关注 AI 工具圈,大概率听说过一个叫 OpenClaw 的东西,中文社区管它叫「龙虾」。这个开源项目在

从 ReAct 到 Plan-and-Execute:AI Agent 推理架构的理解与选择

从 ReAct 到 Plan-and-Execute:AI Agent 推理架构的理解与选择

最近在做一个企业办公 Agent 项目,过程中花了不少时间研究 Agent 的推理架构该怎么选。市面上最主流的两种模式——ReAct 和 Plan-and-Execute——看起来都能用,但深入了解后我发现它们的设计哲学完全不同,适用场景也差异很大。 一、先说一个最基本的问题:Agent 为什么需要"推理"? LLM 本身就能回答问题,为什么还要给它加推理框架? 因为 LLM 只会"说",不会"做"。当用户说"帮我创建一个明天截止的任务",LLM 可以生成一段漂亮的文字描述应该怎么做,但它没有手去操作数据库。Tool(或者叫 Skill)就是给 LLM 装上了手脚——它可以调用接口、查询数据、执行操作。 但问题来了: