简介:这套百度百科模拟系统基于HDWiki开源程序构建,主要面向站长、PHP学习者以及需要私有化部署百科平台的技术团队。它解决了从零搭建在线知识库的核心问题:词条创建/编辑、分类管理、用户权限、全文搜索与审核机制等,交互体验刻意向百度百科看齐,适合作为轻量级百科CMS的参考实现。压缩包体积仅1.95MB,文件数量不多但结构清晰,包含HDWiki主程序、环境安装说明及使用文档,覆盖从服务器配置到数据库导入再到站点上线的完整链路,免去自行摸索环境依赖的时间。目前已有384人学习下载,新手可据此了解百科类系统的部署思路,进阶用户也能借此研究HDWiki的目录组织与二次开发接口。下载后按安装说明逐步操作即可跑通基本功能,再配合文档调整模板和扩展模块,快速生成一个具备百度百科操作习惯的私有知识站点。
1. 百科系统拆解:词条从提交到展示的完整闭环到底怎么做
想找个能直接跑起来看的百科类项目,很容易翻车——多数仓库要么缺数据库脚本,要么前端后端版本对不上,跑起来全是玄学报错。这套百科词条系统是我近期拆过的源码里少见的完整款:Spring Boot 后端、Vue 前端、MySQL 存储,词条浏览、搜索、编辑、审核、历史版本、积分激励一整套都齐了,连管理员审核页面也没偷懒。如果你正在做毕设,或者想找一份能练手前后端分离实战的工程,它最大的价值不是界面像不像某百科,而是把一条业务主线串起来了:一条词条从用户提交,到管理员审核通过,再到首页展示、被用户搜索命中,每一步都有对应的数据表和接口,改起来心里有底。接下来的篇幅,我会把它的技术栈、目录结构、核心代码和运行方式拆开讲,保证你照着做完能跑通。
2. 架构与技术选型:Spring Boot + Vue 前后端分离的百科工程
2.1 技术栈:为什么是这个组合
这套系统的技术选型很典型,属于内容管理类项目最常用的一套组合,没有标新立异,但胜在成熟稳定,社区资料多,遇到问题好搜。
| 层次 | 技术选型 | 用途说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 提供 RESTful 接口,内置 Tomcat |
| 持久层 | MyBatis-Plus | 单表 CRUD 免写 SQL,复杂查询手写 XML |
| 数据库 | MySQL 5.7+ | 存储用户、词条、版本、分类等数据 |
| 权限方案 | JWT + 拦截器 | 无状态登录,前端存储 Token |
| 前端框架 | Vue 2 + Element UI | 管理后台和用户端复用同一套组件库 |
| 构建工具 | Maven 3.6+ | 后端依赖管理 |
后端选 Spring Boot 而不是 JSP/Servlet 老方案,是因为词条系统的核心诉求是“接口复用”。用户端要读词条,管理端要审词条,未来如果做小程序端,暴露 REST 接口比渲染 HTML 页面要省事得多。MyBatis-Plus 在这里特别合适:词条、分类、用户这类表结构相对稳定,单表操作直接用内置方法;而词条搜索这种需要动态拼接条件的场景,用@Select注解或者 XML 又能精细控制 SQL,两不耽误。
前端用 Vue 2 + Element UI 在当前看来不算新潮,但胜在稳定。Vue 2 的生态文档量巨大,Element UI 的表单、弹窗、分页组件几乎就是为这类后台管理系统量身定做的,二次开发时不用花时间造轮子。整套系统跑起来后,静态资源由 Nginx 或者直接通过npm run serve提供,接口走代理转发到后端,典型的开发环境部署方式。
2.2 整体架构与一次请求的完整链路
系统的架构不复杂,甚至可以说只有一个后端服务和一套前端页面,没有拆微服务,也没有引入消息队列。但它不是一个“玩具项目”,因为模块划分是清晰的:
浏览器(Vue 页面) ↓ axios 请求 Vue Router 路由 → 页面组件 → API 模块 ↓ JSON 后端 Controller(参数接收) ↓ Service 层(业务判断、权限校验、积分计算) ↓ Mapper 层(MyBatis-Plus 操控 MySQL) ↓ MySQL 数据库(7 张核心表)以“用户发布一个新词条”为例,链路是这样的:用户在编辑页填写标题和正文,前端把内容以 JSON 形式 POST 到/api/entry/submit,后端EntryController接到请求后先解析 Token 拿到用户 ID,再调用EntryService.submitEntry()方法。Service 层先查询该用户是否登录且状态正常,再检查标题是否重复,然后把词条以“待审核”状态写入词条表,同时写入一条版本快照,最后给用户增加贡献积分。整个链路没有异步操作,全靠事务保证一致性,这对词条系统的数据量来说完全够用,也是很多企业级内容系统初版的实际做法。
2.3 目录结构:源码文件放在哪里
拆开源码包,后端和前端是两个分开的目录。我接触到的这版工程采用标准的 Maven 结构,包名用的是com.example.wiki,看到controller/service/mapper/entity四层结构,基本不会迷路。
backend/ ├── pom.xml └── src/main/java/com/example/wiki/ ├── WikiApplication.java # 启动类 ├── common/ # 统一返回体、全局异常 │ ├── Result.java # { code, message, data } 包装 │ └── GlobalExceptionHandler.java ├── config/ # 拦截器注册、跨域配置 │ ├── WebConfig.java │ └── JwtInterceptor.java ├── controller/ # 接口层 │ ├── EntryController.java │ ├── CategoryController.java │ ├── UserController.java │ └── AdminController.java ├── entity/ # 数据库实体类 │ ├── User.java │ ├── Category.java │ ├── Entry.java │ └── EntryVersion.java ├── mapper/ # MyBatis-Plus 的 Mapper 接口 │ ├── UserMapper.java │ ├── EntryMapper.java │ └── EntryVersionMapper.java └── service/ # 业务逻辑层 ├── EntryService.java ├── EntryServiceImpl.java ├── UserService.java └── CategoryService.java前端是 Vue CLI 标准结构,重点关注src/api和src/views两个目录。api目录下按业务拆分模块,比如entry.js封装了词条相关的所有请求;views下是页面组件,首页、词条详情、词条编辑、登录注册、管理后台的审核列表各占一个目录。
实体类和数据库表一一对应,没有做复杂的 DTO 拆分。这算是一个可以接受的设计取舍:词条系统的字段不算多,直接用实体类传输省掉了大量字段映射代码,但对于有洁癖的开发者,后续改造时可能想拆出EntryVO来避免把content大字段传给列表接口。
3. 核心模块拆解:一条词条从提交到展示的完整链路
3.1 数据库设计:五张表的职责边界
这套系统的核心表一共七张,但真正业务主链路上是五张:用户表、分类表、词条表、词条版本表、积分记录表。还有两张辅助表分别是 Token 黑名单表和操作日志表,整体设计非常克制。
| 表名 | 关键字段 | 职责说明 |
|---|---|---|
user | id, username, password, avatar, points | 用户信息,points 存积分总额 |
category | id, name, description, sort_order | 词条分类,如“科技”“历史”“生活” |
entry | id, title, summary, content, category_id, status, creator_id, view_count | 词条主表,status 标记审核状态 |
entry_version | id, entry_id, title, content, edit_reason, editor_id, version_no | 每次编辑生成一条历史快照 |
points_record | id, user_id, change_value, reason, create_time | 积分流水,记录每次变动原因 |
这里最值得一说的是entry表的status字段。它取值是0/1/2,分别代表待审核、已发布、已驳回。这个字段承担了内容审核状态机的核心职责,所有列表查询都要带上状态条件,避免把未审核的内容暴露给普通用户。我在某个模仿版源码里见过有人不做状态字段,编辑完直接改为发布状态,结果管理后台形同虚设——这种设计在真实内容型系统里是完全不可接受的。
entry_version表是容易被新手忽略但实际很重要的设计。它不存完整增量,而是每次编辑都把当时的标题和正文完整复制一份,带着version_no自增编号。这样做的优点是实现简单、查询某版本时不用做增量合并计算;缺点是数据冗余,每次修改都增大存储。但对百科类系统,正文平均几 KB 到几十 KB,完整快照的成本完全可控,而且可以做到即时发布、随意回滚,属于典型的用空间换时间。
3.2 词条搜索与列表:核心接口是怎么写的
搜索是百科系统的门面,用户在搜索框里敲关键词,希望马上得到相关词条。这套系统的搜索实现走的是常规路子:对标题和正文做LIKE模糊匹配,按匹配度排序。先看EntryController的核心代码:
@RestController @RequestMapping("/api/entry") public class EntryController { @Autowired private EntryService entryService; /** * 搜索词条,支持标题和正文模糊匹配 * @param keyword 搜索关键词 * @param categoryId 分类筛选,可为空 * @param page 当前页码 * @param size 每页条数 */ @GetMapping("/search") public Result search(@RequestParam String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { // 只允许查询已发布状态 Page<Entry> result = entryService.searchEntries(keyword, categoryId, page, size); return Result.success(result); } }参数keyword是必传项,categoryId和分页参数都有默认值。Controller 层只做参数接收和结果包装,真正的业务逻辑下推到 Service。这种写法不是花活,而是为了让 Controller 保持轻薄,方便将来扩展接口时复用 Service 方法。看 Service 实现:
@Override public Page<Entry> searchEntries(String keyword, Long categoryId, int page, int size) { // LambdaQueryWrapper 是 MyBatis-Plus 的条件构造器 LambdaQueryWrapper<Entry> wrapper = new LambdaQueryWrapper<>(); // 状态必须为已发布(1) wrapper.eq(Entry::getStatus, 1); // 关键词模糊匹配:标题优先,其次是正文 if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(Entry::getTitle, keyword) .or() .like(Entry::getContent, keyword)); } // 分类筛选 if (categoryId != null) { wrapper.eq(Entry::getCategoryId, categoryId); } // 排序:浏览量优先,其次按更新时间倒序 wrapper.orderByDesc(Entry::getViewCount) .orderByDesc(Entry::getUpdateTime); // 分页查询 Page<Entry> pageResult = new Page<>(page, size); return entryMapper.selectPage(pageResult, wrapper); }这段代码的核心逻辑有三点:第一,所有查询强制加status = 1条件,让未审核词条永不出现在用户端,这是内容审核的底线;第二,用and配合内部or组合关键词条件,避免LIKE条件优先级错乱导致查出其他状态的数据;第三,排序用浏览量优先,这在没引入搜索权重算法的前提下,是一个简单有效的热度排序策略,比单纯按时间排序更能让好内容浮上来。
如果你拿到源码后想改搜索逻辑,比如增加标签匹配,只需要在这个LambdaQueryWrapper里再加一个or条件,或者关联一张标签表。注意一个细节:这里用的是like而不是likeLeft或likeRight,说明是全文模糊匹配。数据量超过十万条后,%关键词%这种写法会全表扫描,到时候再考虑引入全文索引或 Elasticsearch,现阶段完全够用。
3.3 词条提交与审核:状态机怎么转
提交词条是编辑行为的入口,也是最需要严谨处理的地方。用户提交的可能是新词条,也可能是对已有词条的修改,两种情况的处理逻辑有微妙差别。看EntryServiceImpl的提交方法:
@Override @Transactional(rollbackFor = Exception.class) public void submitEntry(EntrySubmitDTO dto, Long userId) { // 1. 校验标题唯一性 Long count = entryMapper.selectCount( new LambdaQueryWrapper<Entry>() .eq(Entry::getTitle, dto.getTitle())); if (count > 0) { throw new BusinessException("词条标题已存在"); } // 2. 创建主词条记录,状态置为待审核 Entry entry = new Entry(); entry.setTitle(dto.getTitle()); entry.setSummary(dto.getSummary()); entry.setContent(dto.getContent()); entry.setCategoryId(dto.getCategoryId()); entry.setCreatorId(userId); entry.setStatus(0); // 0 待审核 entry.setViewCount(0L); entry.setCreateTime(new Date()); entry.setUpdateTime(new Date()); entryMapper.insert(entry); // 3. 写入第一个版本快照 EntryVersion version = new EntryVersion(); version.setEntryId(entry.getId()); version.setTitle(entry.getTitle()); version.setContent(entry.getContent()); version.setEditorId(userId); version.setEditReason("创建词条"); version.setVersionNo(1); entryVersionMapper.insert(version); // 4. 给用户增加创建积分 userService.addPoints(userId, 10, "创建词条"); }这个方法的重点是@Transactional注解和版本快照逻辑。事务保证三步操作要么全部成功,要么全部回滚,否则可能出现词条建好了、版本表没有记录、积分却扣了的脏数据。我拆过一些半成品项目,这里漏掉事务是最高频的翻车点——用户看到词条列表里有数据,但点进去详情页直接报 500,一查就是版本表没写入导致关联查询失败。
EntrySubmitDTO是一个前端传参对象,包含标题、摘要、正文、分类 ID 四个字段,后端做了@NotBlank非空校验。参数命名都跟页面表单字段一一对应,省去了字段转换的麻烦。在二次开发时,如果你想增加“提交审核意见”功能,就在EntrySubmitDTO里加一个字段,同时在版本快照的edit_reason里存下来。
管理员审核的逻辑在AdminController中,本质是修改status字段。审核通过置为 1,驳回置为 2 并填写驳回原因。这里有一个值得借鉴的小细节:驳回时系统会自动给提交者发送一条站内信,在entry_version表里额外生成一条“驳回记录”版本,方便用户看到自己最后一次提交的内容是什么。这保证了词条页面始终展示的是“最近一次审核通过版本”,而用户编辑时打开的是“最近一次提交版本”,两端不会打架。
3.4 历史版本与积分:两个容易被忽略的联动设计
历史版本表的价值在前面的提交逻辑里已经体现了一部分。它的查询接口设计得很简单,按entry_id倒序查版本列表,每个版本带着版本号、编辑人、编辑时间、编辑理由。用户浏览的是发布版正文,但可以打开侧边栏查看历史记录。这里最实用的是“回滚”功能:
@Override @Transactional(rollbackFor = Exception.class) public void rollbackToVersion(Long entryId, Long versionId, Long operatorId) { // 1. 查出版本快照 EntryVersion target = entryVersionMapper.selectById(versionId); if (target == null) { throw new BusinessException("版本不存在"); } // 2. 查出词条主记录,把正文替换成版本快照的内容 Entry entry = entryMapper.selectById(entryId); entry.setTitle(target.getTitle()); entry.setContent(target.getContent()); entry.setStatus(0); // 回滚后重新进入审核流程 entry.setUpdateTime(new Date()); entryMapper.updateById(entry); // 3. 生成一条新的版本记录,标记为“回滚自版本 X” EntryVersion newVersion = new EntryVersion(); newVersion.setEntryId(entryId); newVersion.setTitle(target.getTitle()); newVersion.setContent(target.getContent()); newVersion.setEditorId(operatorId); newVersion.setEditReason("回滚自版本 " + target.getVersionNo()); newVersion.setVersionNo(entryVersionMapper.selectCount( new LambdaQueryWrapper<EntryVersion>() .eq(EntryVersion::getEntryId, entryId)) + 1); entryVersionMapper.insert(newVersion); }回滚操作最有意思的一点是:它不是直接把词条改成老版本再发布,而是生成一条新版本。这样设计的好处是回滚操作本身也留下了痕迹,任何人打开历史版本列表,都能看到“有人把词条回滚到了某个版本”,这跟 Git 的 revert 而不是 reset 是同一个思路。如果直接改掉主记录,历史全被抹掉,出了问题没法追溯。
积分模块的逻辑更纯粹,addPoints方法内部做两件事:更新user表的points字段累加或扣减,同时在points_record表插入一条流水记录。扣积分的情况也有,比如管理员驳回后发现词条内容违规、恶意编辑,会调用扣分逻辑,保证用户不能无代价地乱提交。这个设计的价值在于,你的二次开发如果要给词条打标签、做排行榜,积分表会成为用户行为分析的基础数据。
4. 本地运行指南:从克隆源码到首页出现词条
4.1 环境准备与配置修改
拿到源码后,先确认本机环境:JDK 1.8 或以上、Maven 3.6+、Node.js 14+、MySQL 5.7+。其中任何一项版本过低,启动时都可能报奇怪的错,尤其是 JDK 版本和 Spring Boot 版本不匹配时,会出现ClassNotFoundException这类摸不着头脑的问题。
后端配置集中在src/main/resources/application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wiki_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里最关键的配置是连接串末尾的characterEncoding=utf8和serverTimezone=Asia/Shanghai。前者保证中文不会乱码,后者保证时间字段读写一致。useSSL=false是本地开发常用设置,因为本机 MySQL 通常没配 SSL 证书,不关掉会报 SSL 警告。
MyBatis-Plus 配置了全局逻辑删除字段deleted,这意味着所有查询会自动追加WHERE deleted = 0条件,物理删除被禁止。这是内容系统的安全考虑,词条即使被删除,数据还留在库里,方便追溯。
4.2 数据库初始化:脚本里到底有什么
源码包的db/目录下放着两份 SQL 文件:schema.sql负责建表,data.sql负责插入初始数据。建表语句不用一行行看完,但建议重点关注几个细节:
- 所有表都带
deleted字段,类型为tinyint,默认 0,这是逻辑删除约定; - 名称索引单独建,
idx_entry_title是普通索引而非唯一索引,因为允许重复标题的可能性存在; - 数据初始化脚本里预置了三类事物:管理员账号、五个分类、十条示例词条。
初始化示例词条非常重要,我第一次拿到源码时直接跑后端,首页一片空白,以为是接口写错了,排查半天才发现是自己没执行data.sql,库里一张词条表空空如也。所以提醒拿到项目的同学,建库后先执行schema.sql,再执行data.sql,顺序别搞反。执行命令:
CREATE DATABASE wiki_db DEFAULT CHARACTER SET utf8mb4; USE wiki_db; SOURCE /path/to/schema.sql; SOURCE /path/to/data.sql;用 utf8mb4 而不是 utf8,是为了支持生僻字和 Emoji 存储。对于百科类系统,这条建议值得优先采纳,否则用户粘贴一段带有特殊符号的百科内容进数据库,直接报Incorrect string value错误,会让你误以为代码有 Bug。
4.3 前端环境与代理配置
前端项目在frontend/目录下,安装依赖和启动的命令是常规的:
cd frontend npm install npm run serve但开发环境最需要注意的配置是vue.config.js里的代理转发。前端默认跑在8081端口,后端跑在8080,跨域请求如果不做代理,浏览器会直接拦截。看关键配置:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 不重写路径,保持 /api 前缀 pathRewrite: { '^/api': '/api' } } } } };这里^/api重写为标准写法,其实等价于不重写,后端接口路径就是/api/entry/search这种风格。如果你改过后端接口前缀,比如统一改成/v1/api,这里就要同步调整^/api的匹配规则,否则所有请求都会 404。这类前后端联调的问题,十有八九都是路径不匹配造成的,排查时先在浏览器 Network 面板看请求到底发到哪个地址、返回什么状态码,比直接抓瞎改配置要快得多。
4.4 启动顺序与访问验证
启动顺序有讲究:先起数据库,再起后端,最后起前端。后端启动时观察控制台日志,看到Tomcat started on port(s): 8080才算成功。前端启动后终端会显示编译进度,看到App running at: http://localhost:8081就可以访问了。
验证系统是否正常工作的路径有几步:
- 打开
http://localhost:8081,首页应显示示例词条列表,分类侧栏可选; - 点击任意词条标题进入详情页,正文正常渲染,浏览量 +1;
- 点击右上角“登录”,用初始化脚本里预置的管理员账号登录(用户名 admin,密码预置为 admin123);
- 进入“词条管理”页面,能看到一个待审核状态的测试词条,点击“通过”,词条出现在首页列表且状态变为已发布。
跑通这四步,整个系统的主链路就通了。如果哪一步没达到预期,先回到数据层面排查——检查对应表有没有数据、状态值是不是正确,大部分逻辑问题都能从数据状态里看出来。
5. 避坑与排查:四个亲身踩过的典型问题
5.1 中文乱码:不是代码的错,是连接串的错
现象:后端返回的词条正文中,中文全部变成????,英文和数字正常。第一次遇到时我一度怀疑是编辑器的编码问题,折腾了半天前端。
原因:MySQL 连接串里漏了characterEncoding=utf8参数。Spring Boot 默认连接时如果没指定字符集,读取数据就会用数据库默认的 latin1,中文自然变成问号。
解决:在application.yml的连接串上加参数,同时确认数据库和表本身是 utf8mb4 编码。改完连接串后重启后端即可。从那以后,我每次新建项目的第一件事就是在连接串里把字符集、时区、SSL 这三个参数写死,省得后面被动排查。
5.2 前端代理 404:命中/api前缀的路径问题
现象:前端首页能打开,但词条列表接口返回 404,Network 面板里请求 URL 是http://localhost:8080/api/entry/list,改到 8081 端口访问反而正常。
原因:vue.config.js里配置的代理路径是/api,但后端接口实际路径是/entry/list,没有带/api前缀,代理匹配不到,请求被直接转发到了 8081 端口的前端开发服务器上,前端没有这个接口,自然 404。
解决:统一前后端接口前缀——后端在application.yml配置server.servlet.context-path: /api,或者前端代理时匹配/entry。规范的做法是后端所有接口统一加/api前缀,这样将来如果要挂到 Nginx 下,一条location /api规则就能满足所有转发需求。
5.3 时间字段变成数组:JSON 序列化格式没指定
现象:词条详情页的发布时间显示成一串数字,控制台打印出来的数据是"createTime": [2025, 3, 15, 10, 30, 25],前端格式化函数直接报错。
原因:后端用的是java.util.Date,默认 Jackson 序列化时没有指定格式,序列化出来的是一组年月日时分秒的数组结构,这不是前后端约定的yyyy-MM-dd HH:mm:ss字符串。
解决:在application.yml里配置spring.jackson.date-format: yyyy-MM-dd HH:mm:ss和time-zone: Asia/Shanghai。同时确保实体类的createTime字段没有额外加@JsonFormat注解覆盖了全局配置。这类问题在拆其他项目时也常见,属于后端全局配置不严导致的接口契约不一致。
5.4 浏览量不准确:缓存思路没落地
现象:每次刷新词条详情页,浏览量 +1,刷新二十次就加了 20,词条详情里显示的浏览量远超合理值。
原因:详情接口在 Service 层直接执行UPDATE entry SET view_count = view_count + 1 WHERE id = ?,没有做去重、没有限流、没有缓存。一个人盯着页面反复刷新,浏览量就失真了。
解决:在详情接口里先判断同一 IP 或同一用户在一小时内是否已经访问过该词条,用一个 Redis 键记录访问记录,或者至少在内存里做个简单的时间窗口过滤。如果不想引入 Redis,也可以通过临时表记录访问日志、定时汇总,把浏览量更新从高频的详情接口里剥离出去。
6. 二次开发思路:给百科系统加一个「版本对比」功能
词条的历史版本列表已经有了,但缺少“对比两个版本差异”的能力。目前用户只能点开某个版本查看当时的完整内容,没法直观看到前后改了什么。这个功能加进来,百科系统的编辑协作体验会提升一个档次,实现成本也不算高。
后端接口设计:新增一个GET /api/entry/compare?entryId=1&v1=2&v2=5接口,参数是词条 ID 和两个版本号。Service 层查出版本记录后用最简单的“按行拆分对比”算法,输出每行文本及其状态(新增、删除、不变):
@Override public List<DiffLine> compareVersions(Long entryId, Integer versionA, Integer versionB) { EntryVersion vA = versionMapper.selectOne( new LambdaQueryWrapper<EntryVersion>() .eq(EntryVersion::getEntryId, entryId) .eq(EntryVersion::getVersionNo, versionA)); EntryVersion vB = versionMapper.selectOne( new LambdaQueryWrapper<EntryVersion>() .eq(EntryVersion::getEntryId, entryId) .eq(EntryVersion::getVersionNo, versionB)); if (vA == null || vB == null) { throw new BusinessException("版本不存在"); } String[] linesA = vA.getContent().split("\n"); String[] linesB = vB.getContent().split("\n"); List<DiffLine> result = new ArrayList<>(); // 简化实现:按最小行数遍历,标记差异行 List<String[]> maxLines = linesA.length >= linesB.length ? Arrays.asList(linesA, linesB) : Arrays.asList(linesB, linesA); int minLen = Math.min(linesA.length, linesB.length); for (int i = 0; i < minLen; i++) { if (!linesA[i].equals(linesB[i])) { result.add(new DiffLine(i + 1, "old", linesA[i])); result.add(new DiffLine(i + 1, "new", linesB[i])); } } // 多出的行统一标记为新增 for (int i = minLen; i < maxLines.get(0).length; i++) { result.add(new DiffLine(i + 1, "new", maxLines.get(0)[i])); } return result; }严格来说这不是真正的 diff 算法,因为按固定行号对比会漏掉“删除某一行导致后续全部错位”的情况,但对百科词条这种以段落为单位的文本,段落级对比足够用了。真正要做的优化是把对比粒度从“行”提升到“段落”,用 List 公共子序列算法找出移动块,如果你有兴趣可以接着实现。
前端在历史版本列表页加一个“对比选中版本”的按钮,选中两个版本后弹出对话框,左侧显示旧版本,右侧显示新版本,差异行用红色和绿色背景高亮。Element UI 的Dialog组件加两个div并排,代码量不大,但给用户的感知提升非常大。
验证方法很直接:用两个版本号调接口,看返回的DiffLine列表是否与预期一致;再在前端页面手动编辑一段词条内容,新增几行、删除几行,回到版本列表选中新旧版本,检查高亮是否正确。等这个功能做完,你会发现整个系统的“编辑协作”味道就出来了——配合原有的积分激励和审核流,活脱脱一个小型 Wiki 引擎。
从那以后,我每次拿到这类带历史版本的源码,都会先强制走一遍“查版本表 → 写回滚 → 做对比”的流程,确保自己对这套数据模型的理解是透彻的,再做前端改造,心里才有底。希望这份拆解能帮你在跑通、改造这套百科系统时少走几步弯路。
本文还有配套的精品资源,点击获取