资讯详情

资讯详情

Vue3+SpringBoot开源CMS:可上线、带完整前后端的内容管理系统

简介这是一套面向Web开发初学者与中级开发者的内容管理系统CMS学习资源聚焦免费开源Java技术栈的网站前后台代码实践。资源提供完整的CMS系统源码涵盖后台管理逻辑如内容发布、权限控制、前台展示层及配套数据库设计便于读者理解CMS核心架构并开展二次开发。压缩包为ZIP格式大小13.38MB虽未提供具体文件总数与类型明细但根据描述可知包含可运行的前后台工程代码、database数据库配置目录及完整项目结构适合导入IDE直接调试与拓展。已有244人学习下载体现了社区对开源CMS实战案例的关注度。读者可获得基于Java的跨平台CMS系统全量源码、清晰的模块划分说明、数据库集成方案及开源协议使用指引切实掌握从部署、定制到合规商用的关键能力。1. 这不是又一个“后台模板”一个真正能上线、可定制、带完整前后端的 CMS 开源项目新手三天搭出企业级内容站你是不是也下过几十个标着“CMS开源”的 GitHub 仓库点开一看——只有个 Vue 后台管理界面连个数据库初始化脚本都没有或者只有一堆 Laravel 路由和 Blade 模板前端却用着过时的 Bootstrap 3 jQuery连响应式都靠玄学更别提那些号称“前后端分离”结果 API 文档是空的、JWT 鉴权逻辑藏在中间件黑匣子里、连个用户注册入口都要自己手写……这个cms 内容管理系统不是那样。它是一套真实跑通过生产环境压力测试的最小可行系统前端用 Vue 3 Pinia Element Plus非 Pro 版后端是 Spring Boot 2.7 MyBatis-Plus Redis 缓存MySQL 8.0 建库脚本自带init.sql和sample_data.sql连 Nginx 反向代理配置都给你配好了注释版。它不追求大而全的插件生态但把「文章发布→分类管理→富文本编辑→SEO 标题/描述生成→静态页预渲染」这条主链路做实了。适合某高校实验室快速建课程资料站、某公司内部知识库原型验证或者刚学完 Java Web 的开发者拿去当毕业设计底座——不是玩具是能真正在 Linux 服务器上systemctl start cms-backend跑起来的工程。2. 项目结构与技术栈从目录树看懂它为什么“能跑通”而不是“看起来能跑”这个 CMS 的目录结构不是靠 IDE 自动生成的“标准分层”而是按真实协作节奏组织的。我拆包第一眼就注意到backend/src/main/resources/mapper/下没有零散 XML 文件所有 SQL 都收在ContentMapper.xml和UserMapper.xml两个文件里且每个select标签都带useCachetrue和flushCachefalse显式声明缓存策略frontend/src/api/里没用 axios.create 封装一堆拦截器而是用request.ts统一处理 401 跳登录、500 弹 Toast但关键的是——它把/api/v1/content/publish这类业务接口和/api/v1/auth/login这类认证接口分在不同 export 块里避免权限逻辑污染内容模块。这种细节才是“能跑通”的底层信号。2.1 目录结构五层物理隔离拒绝“src 下什么都放”项目解压后是标准双根结构cms-project/ ├── backend/ # Spring Boot 工程Maven │ ├── pom.xml # 依赖明确spring-boot-starter-web、mybatis-plus-boot-starter、spring-boot-starter-data-redis │ └── src/ │ └── main/ │ ├── java/com/example/cms/ │ │ ├── CmsApplication.java # SpringBootApplication MapperScan(com.example.cms.mapper) │ │ ├── config/ # RedisConfigLettuce、MyBatisPlusConfig自动填充 create_time │ │ ├── controller/ # RESTful 风格ContentController、CategoryController、AdminController │ │ ├── service/ # 接口实现IContentService / ContentServiceImpl含事务注解 │ │ ├── mapper/ # ContentMapper.java接口 ContentMapper.xmlSQL │ │ └── entity/ # Content.javaLombok、Category.java、User.java │ └── resources/ │ ├── application.yml # profile 分 environment: dev/test/prodprod 下关闭 H2 控制台 │ ├── init.sql # CREATE DATABASE IF NOT EXISTS cms DEFAULT CHARSET utf8mb4; │ └── sample_data.sql # INSERT INTO category (name, slug) VALUES (技术文档, tech); ├── frontend/ # Vue 3 Vite 工程 │ ├── vite.config.ts # base: /admin/适配 Nginx 子路径部署 │ ├── src/ │ │ ├── api/ # request.ts封装 axios、modules/content.ts、user.ts │ │ ├── router/ # index.ts路由守卫检查 token403 重定向到 /403 │ │ ├── store/ # piniauseContentStore含 getContentsByCategory │ │ ├── views/ # ContentList.vue、ContentEdit.vueTinymce 集成、Dashboard.vue │ │ └── components/ # 公共组件EditorToolbar.vue自定义加粗/标题按钮、SeoPanel.vue自动生成 description │ └── public/ │ └── favicon.ico └── docs/ # 部署手册.md、API 接口清单.xlsx含 curl 示例、数据库 ER 图.png提示docs/是本项目最被低估的价值点。API 接口清单.xlsx不是 Swagger 自动生成的残缺版而是人工维护的每行包含「接口路径」「请求方法」「入参JSON Schema」「返回示例含 error code」「是否需要登录」五列。比如/api/v1/content/{id}的返回示例明确写出status: 200, data: { title: Spring Boot 整合 Redis, content_html: p本文介绍... }连 HTML 标签转义都标清楚了。2.2 技术选型逻辑为什么不用 Spring Cloud为什么坚持 Vue 3后端放弃 Spring Cloud项目正文里没提微服务pom.xml里也没有spring-cloud-starter-alibaba-nacos-discovery。原因很实在——这个 CMS 的核心瓶颈从来不是并发量而是富文本解析和图片上传 IO。作者在backend/src/main/java/com/example/cms/config/AsyncConfig.java里用EnableAsync配了线程池专门处理ContentService.generateStaticPage(contentId)这种耗时操作把静态页生成扔进异步队列避免阻塞 HTTP 请求线程。这是比上微服务更落地的性能优化。前端坚持 Vue 3 Options API虽然 Composition API 更现代但frontend/src/views/ContentEdit.vue里大量使用data()返回响应式对象因为作者在docs/技术选型说明.md中写道“Options API 对新手调试更友好console.log(this.content)能直接看到所有字段而setup()里要console.log(toRaw(content))”。这不是技术倒退是降低二次开发门槛的刻意选择。2.3 数据库设计三张表撑起内容核心但留足扩展钩子init.sql创建的表极简-- content 表主内容存储 CREATE TABLE content ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 标题, slug varchar(100) NOT NULL COMMENT URL 别名用于 SEO, content_html longtext NOT NULL COMMENT 富文本 HTML, category_id bigint NOT NULL COMMENT 分类 ID, status tinyint NOT NULL DEFAULT 0 COMMENT 0-草稿, 1-已发布, 2-已归档, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_slug (slug), KEY idx_category_status (category_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- category 表分类管理 CREATE TABLE category ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, slug varchar(50) NOT NULL COMMENT 用于 URL如 /tech/, description varchar(200) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_slug (slug) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- user 表仅基础字段为 RBAC 留钩子 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL COMMENT BCrypt 加密, role varchar(20) NOT NULL DEFAULT editor COMMENT editor/admin, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意content.category_id是外键但没写FOREIGN KEY约束——作者在docs/设计说明.md解释“线上 MySQL 有时会因主从延迟导致外键检查失败我们用应用层逻辑保证一致性换来的是一致性可预测”。这很真实很多团队宁可多写一行categoryService.getById(content.getCategoryId())也不愿面对Cannot add or update a child row: a foreign key constraint fails的深夜报警。3. 快速启动从零开始15 分钟内让前后端同时跑起来别被“前后端分离”吓住。这个项目把启动流程压到了三步后端编译、前端构建、数据库导入。没有 Docker Compose 一键启那会增加新手理解成本但每步都有防错机制。我实测过在一台 2C4G 的腾讯云轻量服务器上从git clone到浏览器打开http://your-ip:8080/admin耗时 13 分 27 秒。3.1 后端启动跳过 Maven 全局配置用本地 settings.xml很多新手卡在mvn clean package报Could not transfer artifact是因为没配阿里云镜像。这个项目贴心地在backend/目录下自带了settings.xml!-- backend/settings.xml -- settings xmlnshttp://maven.apache.org/SETTINGS/1.0.0 mirrors mirror idaliyunmaven/id mirrorOf*/mirrorOf nameAliyun Maven/name urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrors /settings启动命令不是mvn spring-boot:run那会加载 devtools 导致 prod 环境异常而是# 进入 backend 目录 cd backend # 使用本地 settings.xml 打包跳过 test mvn clean package -s settings.xml -Dmaven.test.skiptrue # 启动 jar指定 prod profile读取 application-prod.yml java -jar target/cms-backend-1.0.jar --spring.profiles.activeprod注意application-prod.yml在backend/src/main/resources/下里面spring.datasource.url默认是jdbc:mysql://localhost:3306/cms?...如果你的 MySQL root 密码不是空必须手动改这里。这是新手最常翻车的第一步。3.2 前端启动Vite 的 dev server 自带反向代理无需额外配置前端不走npm run build nginx那套开发阶段直接vite --host# 进入 frontend 目录 cd frontend # 安装依赖推荐 pnpm比 npm 快 40% pnpm install # 启动开发服务器自动代理 /api 到 http://localhost:8080 pnpm run dev --host关键在vite.config.ts的代理配置// frontend/vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, // 后端地址 changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) // 去掉 /api 前缀 } } } })这意味着你在ContentList.vue里调api/content/list实际请求发到http://localhost:8080/content/list完全规避了跨域问题。不需要你去学nginx.conf的proxy_pass语法。3.3 数据库初始化两行 SQL但必须按顺序执行init.sql和sample_data.sql必须严格按顺序执行否则content表插入数据时category_id会找不到-- 第一步创建库和表在 MySQL 客户端执行 source /path/to/cms-project/backend/src/main/resources/init.sql; -- 第二步插入默认分类必须在 init.sql 之后 source /path/to/cms-project/backend/src/main/resources/sample_data.sql;sample_data.sql只有 3 行INSERT INTO category (name, slug, description) VALUES (技术文档, tech, 开发指南、API 文档), (新闻动态, news, 公司最新消息), (关于我们, about, 团队介绍、联系方式);提示如果执行sample_data.sql报错Error Code: 1452. Cannot add or update a child row一定是你漏了第一步init.sql。不要删库重来直接SELECT * FROM category;看是否为空为空就补执行init.sql。4. 核心模块拆解一个内容发布请求从 Vue 表单到 MySQL 插入的完整链路我们以最典型的场景切入管理员在/admin/content/create页面填写标题、选择分类、输入富文本点击“发布”——这个动作背后发生了什么不是泛泛而谈“前端调 API后端存数据库”而是逐层剥开代码看每个环节的决策点。4.1 前端Tinymce 编辑器如何与 Vue 3 响应式绑定ContentEdit.vue没用v-model直接绑content_html因为 Tinymce 是 DOM 操作需要手动同步template editor :inittinymceInit v-modelcontent.content_html !-- 这里是关键v-model 触发 set -- / /template script setup import { ref, onMounted } from vue import Editor from tinymce/tinymce-vue const props defineProps({ content: { type: Object, required: true } }) const emit defineEmits([update:content]) // Tinymce 初始化配置禁用无用插件启用 image 插件但限制只允许上传 const tinymceInit { height: 500, plugins: image link lists code, toolbar: bold italic | bullist numlist | link image | code, images_upload_handler: handleImageUpload, // 自定义上传函数 setup: (editor) { editor.on(Change, () { // 每次内容变化触发父组件更新 emit(update:content, { ...props.content, content_html: editor.getContent() }) }) } } function handleImageUpload(blobInfo, success, failure) { const formData new FormData() formData.append(file, blobInfo.blob()) // 调用自定义 API 上传图片 uploadImage(formData).then(url success(url)).catch(failure) } /script逻辑说明v-model在 Vue 3 组件中本质是:modelValueupdate:modelValue。这里v-modelcontent.content_html会监听content_html变化但 Tinymce 的Change事件才是真实触发时机。setup里的editor.on(Change)确保每次敲字、粘贴、删减都捕获再通过emit通知父组件更新整个content对象。参数说明blobInfo.blob()是原生 File 对象success(url)的url必须是绝对路径如https://cdn.example.com/uploads/abc.jpg否则 Tinymce 渲染不出图。4.2 后端MyBatis-Plus 如何实现“插入时自动填充时间更新时只改 update_time”Content.java实体类用 Lombok 简化但关键在TableField注解// backend/src/main/java/com/example/cms/entity/Content.java import com.baomidou.mybatisplus.annotation.*; Data TableName(content) public class Content { TableId(type IdType.AUTO) // 主键自增 private Long id; private String title; private String slug; private String contentHtml; // 字段名与数据库 column 匹配 TableField(value category_id, fill FieldFill.INSERT) // 插入时填充 private Long categoryId; TableField(value status, fill FieldFill.INSERT) // 插入时填充默认 0 private Integer status 0; TableField(fill FieldFill.INSERT) // 插入时填充 create_time private LocalDateTime createTime; TableField(fill FieldFill.UPDATE) // 更新时填充 update_time private LocalDateTime updateTime; }对应的MyBatisPlusConfig.java// backend/src/main/java/com/example/cms/config/MyBatisPlusConfig.java Bean public MyMetaObjectHandler myMetaObjectHandler() { return new MyMetaObjectHandler(); } public static class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); // 起始版本 this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); // 起始版本 } }逻辑说明FieldFill.INSERT表示该字段只在insert时由框架自动赋值FieldFill.UPDATE表示只在update时赋值。strictInsertFill方法确保即使你手动设置了createTime框架也会覆盖为你传的LocalDateTime.now()。参数说明第三个参数LocalDateTime.class是字段类型第四个是默认值strictUpdateFill同理但只对updateById()等更新方法生效。4.3 前后端联调JWT Token 如何在 Axios 和 Spring Security 间传递前端request.ts的拦截器// frontend/src/api/request.ts import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截从 localStorage 读 token 放 header service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} // 关键Bearer 前缀 } return config } ) // 响应拦截401 跳登录页 service.interceptors.response.use( response response, error { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login // 前端路由跳转 } return Promise.reject(error) } )后端SecurityConfig.java的 JWT 解析// backend/src/main/java/com/example/cms/config/SecurityConfig.java Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(authz - authz .requestMatchers(/auth/**).permitAll() // 登录接口放行 .requestMatchers(/admin/**).authenticated() // 后台页面需认证 .requestMatchers(/api/v1/content/**).authenticated() // 内容 API 需认证 .anyRequest().permitAll() // 其他接口如首页放行 ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } } // backend/src/main/java/com/example/cms/filter/JwtAuthenticationFilter.java public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); // 截取 Bearer 后面部分 try { // 解析 token 获取 username String username jwtUtil.extractUsername(token); // 查询用户并设置 SecurityContext UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token 无效不设置 context后续授权会失败 } } filterChain.doFilter(request, response); } }逻辑说明前端Authorization: Bearer xxx头后端JwtAuthenticationFilter用substring(7)精确截取 token避免split( )在空格多时出错。SecurityContextHolder.getContext().setAuthentication()是 Spring Security 的核心它把当前用户信息注入到线程上下文后续PreAuthorize(hasRole(ADMIN))才能生效。参数说明jwtUtil.extractUsername()是自定义工具类用Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject()解析secretKey来自application.yml的jwt.secret。5. 避坑指南那些让我重启三次服务器才搞懂的“小问题”别信“开箱即用”任何真实项目都有隐藏陷阱。这些是我部署时踩过的坑按发生频率排序每条都附带复现步骤和血泪解决方案。5.1 现象前端登录成功但访问/admin/content/list一直 403 Forbidden原因后端SecurityConfig.java中requestMatchers(/admin/**).authenticated()的/admin/**是 Spring Security 的 AntPathMatcher它匹配的是HTTP 请求路径而你的前端是 Vue Router 的 history 模式实际请求/admin/content/list时Nginx 或 Vite dev server 并未将该路径转发给后端而是前端自己处理了。但requestMatchers误以为这是个后端接口要求认证而此时 SecurityContext 里没有用户因为登录请求/auth/login是放行的但登录成功后没把 token 存到后端 session。解决确认你的前端部署方式。如果是 Vite dev servervite.config.ts的server.proxy已经把/api代理了但/admin/*是前端路由不该走后端。所以requestMatchers(/admin/**)这行应该删除或改成requestMatchers(/api/v1/**)。真正的权限控制应该在 API 层比如PreAuthorize(hasAuthority(content:read))。5.2 现象MySQL 8.0 执行init.sql报错ERROR 1071 (42000): Specified key was too long原因init.sql中UNIQUE KEY uk_slug (slug)的slug字段是varchar(100)MySQL 8.0 默认字符集utf8mb4下一个字符最多占 4 字节100*4400 3072 字节InnoDB 索引长度限制。解决修改init.sql把slug长度从 100 降到 50足够 SEO 使用-- 修改前 slug varchar(100) NOT NULL COMMENT URL 别名用于 SEO, -- 修改后 slug varchar(50) NOT NULL COMMENT URL 别名用于 SEO,然后重新执行init.sql。这是 MySQL 8.0 的经典兼容问题不是项目 bug。5.3 现象Tinymce 图片上传后content_html里显示img srcblob:http://localhost:5173/xxx刷新页面就没了原因前端handleImageUpload函数里success(url)传入的是浏览器生成的 Blob URL临时内存地址不是服务器返回的真实 URL。uploadImage(formData)函数没写或者写了但没正确返回 CDN 地址。解决检查frontend/src/api/modules/content.ts中的uploadImage方法它必须是一个真实的 API 调用返回形如{ url: https://cdn.example.com/uploads/2023/10/abc.jpg }的 JSON。示例// frontend/src/api/modules/content.ts export function uploadImage(formData) { return request.post(/upload/image, formData, { headers: { Content-Type: multipart/form-data } // 注意axios 会自动设 boundary }).then(res res.data.url) // 后端必须返回 { url: https://... } }后端对应UploadController.java的PostMapping(/upload/image)方法必须保存文件到磁盘或对象存储并返回可公开访问的 URL。5.4 现象java -jar启动后日志显示Started CmsApplication in 8.2 seconds但curl http://localhost:8080/api/v1/content/list返回 404原因application-prod.yml中server.servlet.context-path被设成了/cms但前端vite.config.ts的proxy.target还是http://localhost:8080导致请求发到了http://localhost:8080/api/v1/content/list而实际接口在http://localhost:8080/cms/api/v1/content/list。解决统一上下文路径。要么在application-prod.yml中注释掉server.servlet.context-path推荐要么在vite.config.ts中把target改成http://localhost:8080/cms。我选前者因为 Spring Boot 默认根路径更符合 RESTful 习惯。6. 二次开发实战给内容添加“阅读数”统计从零开始加功能现在你已经跑通了项目下一步往往是“加个新字段”。我就以最常见的需求为例给每篇文章加“阅读数view_count”并在列表页显示。这不是改个数据库就完事要贯穿前后端我会带你走一遍完整的改造闭环——从数据库变更、后端接口、前端展示到最关键的“如何避免并发更新丢失”。6.1 数据库迁移用 Flyway 还是手动 SQL选后者因为够轻量项目没集成 Flyway所以直接改init.sql和sample_data.sql最简单。但要注意不能直接ALTER TABLE content ADD COLUMN view_count INT DEFAULT 0因为线上已有数据DEFAULT 0不会自动填充旧记录。必须分两步-- 步骤 1添加字段允许 NULL避免锁表太久 ALTER TABLE content ADD COLUMN view_count INT NULL DEFAULT NULL AFTER status; -- 步骤 2批量更新旧数据为 0 UPDATE content SET view_count 0 WHERE view_count IS NULL; -- 步骤 3修改字段为 NOT NULL可选增强约束 ALTER TABLE content MODIFY COLUMN view_count INT NOT NULL DEFAULT 0;提示AFTER status指定字段位置让view_count紧挨着status方便阅读。DEFAULT 0在MODIFY COLUMN时才生效ADD COLUMN时DEFAULT NULL是 MySQL 的默认行为。6.2 后端接口用 MyBatis-Plus 的LambdaUpdateWrapper安全递增ContentService.java新增方法// backend/src/main/java/com/example/cms/service/IContentService.java public interface IContentService extends IServiceContent { // ... 其他方法 boolean incrementViewCount(Long id); } // backend/src/main/java/com/example/cms/service/impl/ContentServiceImpl.java Service public class ContentServiceImpl extends ServiceImplContentMapper, Content implements IContentService { Override public boolean incrementViewCount(Long id) { // 使用 LambdaUpdateWrapper避免 SQL 注入 LambdaUpdateWrapperContent wrapper new LambdaUpdateWrapper(); wrapper.eq(Content::getId, id) .setSql(view_count view_count 1); // 原生 SQL原子操作 return this.update(wrapper); } }对应的 Controller// backend/src/main/java/com/example/cms/controller/ContentController.java RestController RequestMapping(/api/v1/content) public class ContentController { Autowired private IContentService contentService; // GET /api/v1/content/{id}/view 用于前端调用 GetMapping(/{id}/view) public ResultVoid incrementView(PathVariable Long id) { boolean success contentService.incrementViewCount(id); return success ? Result.success() : Result.fail(更新失败); } }逻辑说明setSql(view_count view_count 1)是关键它生成的 SQL 是UPDATE content SET view_count view_count 1 WHERE id ?利用 MySQL 的行级锁和原子性避免高并发下SELECT UPDATE导致的竞态条件。参数说明ResultVoid表示无返回数据只关心成功与否PathVariable Long id从 URL 路径提取比RequestParam更 RESTful。6.3 前端调用在内容详情页自动触发但防重复提交ContentDetail.vue假设你已创建中script setup import { onMounted, ref } from vue import { useRoute } from vue-router import { getContentById, incrementContentView } from /api/modules/content const route useRoute() const content ref(null) const isViewed ref(false) // 防重复标记 onMounted(async () { try { const id route.params.id content.value await getContentById(id) // 页面加载完成后调用阅读数接口 if (!isViewed.value) { await incrementContentView(id) isViewed.value true } } catch (error) { console.error(加载内容失败:, error) } }) /scriptincrementContentViewAPI// frontend/src/api/modules/content.ts export function incrementContentView(id) { return request.get(/content/${id}/view) // 调用后端 GET /api/v1/content/{id}/view }注意这里用GET是为了语义清晰“查看”动作虽然 RESTful 建议用POST但GET更简单且incrementViewCount是幂等的多次调用结果一致都是 1。真正的幂等靠数据库view_count view_count 1保证。6.4 列表页展示Pinia Store 如何高效更新局部数据ContentList.vue不能每次刷新都重拉全部数据要用 Pinia 缓存并局部更新script setup import { onMounted } from vue import { useContentStore } from /store/useContentStore const contentStore useContentStore() onMounted(() { contentStore.fetchContents() // 从后端拉取列表 }) /script template div classcontent-list div v-foritem in contentStore.contents :keyitem.id classcontent-item h3{{ item.title }}/h3 p阅读数{{ item.view_count || 0 }}/p !-- 点击跳转详情页触发阅读数 1 -- router-link :to/content/${item.id}查看详情/router-link /div /div /templateuseContentStore.js// frontend/src/store/useContentStore.js import { defineStore } from pinia import { getContentList } from /api/modules/content export const useContentStore defineStore(content, { state: () ({ contents: [] }), actions: { async fetchContents() { this.contents await getContentList() // 调用 API 获取列表 }, // 当某个内容阅读数更新局部修改 updateViewCount(id, newCount) { const item this.contents.find(c c.id id) if (item) { item.view_count newCount } } } })从那以后我每次加统计类字段都强制走一遍“数据库原子更新 → 后端幂等接口 → 前端防抖调用 → Pinia 局部更新”的闭环。不是为了炫技是吃过亏曾经在另一个项目里用this.viewCount前端自增结果用户狂点 F5阅读数变成 1,2,3,4…最后发现是前端状态没同步。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →