上篇回顾: 《马克沁机枪上阵:Claude Code 如何用 AI 碾压研发战场》
上篇详细记录了用 Claude Code 从零搭建百度百科自动发布系统后端的全过程------解决 Spring 循环依赖、Hibernate 类型错误、Playwright 选择器调试,以及词条类型选择、义项名填写、提交模态框处理等一系列实战经验。感兴趣的读者可以先移步阅读:
https://www.toutiao.com/article/7618051075811181090/
一、后方稳固,兵锋向前
后端主炮阵地已经建立------词条搜索、自动填写、定时提交、截图存档,一气呵成。但这套系统有个致命的短板:所有操作都要通过 REST API 裸调,没有任何可视化界面。词条状态靠 MySQL 查询,任务日志靠 SSH 翻文件,出了问题只能盯着终端猜。
这显然不是一支现代化作战部队该有的指挥系统。
前端,必须开。
技术选型用时不超过三分钟:Vue 3 + Vite + Element Plus。没有花哨的理由------Vue 3 的 Composition API 写组件足够直观,Element Plus 开箱即用的 Table/Form/Dialog 组件直接省掉 80% 的 UI 工作量,Vite 的热重载让调试快如闪电。用 AI 写前端,工具越熟悉越省力,这不是玄学,是弹药选择问题。
第一条命令交给 Claude Code:
创建 Vue 3 + Vite 前端项目,集成 Element Plus,配置 axios 代理到后端 8080 端口,路由包含任务管理、账号管理两个主模块。
炮声响起,脚手架搭完。
二、任务管理------前线指挥台
TaskList.vue 是整个系统的作战地图。打开页面,所有发布任务一览无遗:任务编号、词条名称、当前状态、词条类型(新建/编辑),以及最终的词条链接。
状态系统是这个页面的核心。最初后端只有 SUCCESS 一个终态,反映不了"已提交百度、等待审核"和"审核通过正式上线"这两种截然不同的情况。一番讨论之后,状态机重构成了六个节点:
PENDING → RUNNING → SUBMITTED → APPROVED
↘ FAILED
EDITING
- PENDING:初始状态,等待触发
- RUNNING:Playwright 正在操控浏览器
- SUBMITTED:内容已提交百度,审核进行中
- APPROVED:百科审核通过,词条正式上线
- EDITING:验证发布过程中(占位状态,防并发)
- FAILED:任何环节出错
数据库里旧的 SUCCESS 记录,一条迁移 SQL 直接搞定:
UPDATE publish_task SET status = 'SUBMITTED' WHERE status = 'SUCCESS';
列表页的操作列经历了一次迭代。最初方案是把"运行""验证发布""编辑""删除"全摆出来,结果一行里挤了五个按钮,密集得像子弹上膛------视觉上是乱的。
最终方案:一个直接按钮(详情)+ 一个三点下拉菜单(所有其余操作)。宽度从 220px 压缩到 110px,干净利落。下拉菜单按使用频率排序:运行、验证发布(仅 SUBMITTED 状态可见,绿色高亮)、编辑,分隔线之后是橙色的重置状态和红色的删除。
词条链接列也做了区分:状态为 APPROVED 时显示可点击的蓝色链接;状态为 SUBMITTED 时显示灰色"审核中"提示------同样的 URL,但语义完全不同,不应该让用户误以为词条已经上线。
截图:任务列表页面(含状态筛选下拉、操作列下拉菜单展开状态)

三、任务表单------弹药装填台
新建和编辑任务共用一套表单:TaskForm.vue。
表单字段对应后端 PublishTask 的核心字段:
- 词条名称:必填,这是整个发布流程的起点
- 义项名:百科词条的副标题,如"蓝师傅旗下AI智能客服应用"
- 词条类型:级联选择器,从后端 LemmaType 表动态加载,结构是"主分类 → 子分类"
- 概述:可选,不填则自动截取正文前 100 字
- 正文内容:多行文本框,这是实际填入百科的内容
- 指定账号:下拉选择,显示每个账号的当日用量(dailyUsed/dailyLimit),不指定则自动轮换
词条类型选择器值得单独说一句。后端存储格式是 "科技/软件" 这样的斜杠分隔字符串,但 Element Plus 的 el-cascader 需要路径数组 ["科技", "科技/软件"]。提交时取路径的最后一个元素,回显时把字符串拆开还原成路径------一来一回,翻译层写了约十行代码。
底部有两个提交按钮:仅保存 (不触发执行)和保存并运行(保存后立即触发 Playwright)。后者点击后直接跳转到任务详情页,SSE 连接自动建立,执行过程实时呈现。
截图:任务新建/编辑表单

四、任务详情------作战进程实时追踪
TaskDetail.vue 是整个系统里技术含量最高的一页。
页面顶部是任务基本信息卡:任务编号、状态(带旋转图标的 loading 效果)、词条名称、词条类型(新建/编辑 Tag)、词条链接、失败原因。
右上角按钮区根据状态动态呈现:
- 任务处于 SUBMITTED:显示绿色「验证发布」按钮(带 loading 旋转)
- 任务处于 RUNNING / EDITING:显示橙色「重置状态」按钮(用于卡死时手动解锁)
- 任何非运行中状态:「重新运行」按钮可用
页面下半部分是执行步骤时间线。每一个 Playwright 操作步骤(LOGIN、SEARCH、CREATE、FILL、SUBMIT 等)都会在这里实时出现,显示步骤名、消息、耗时。绿色表示成功,红色表示失败。任务运行中时,时间线末尾会有一个持续旋转的橙色 loading 节点。
实时性靠 SSE(Server-Sent Events)实现。 后端 SseService 维护每个 taskNo 对应的 SseEmitter,每完成一个 Playwright 步骤就推送一条 log 事件,任务完成时推送 done 事件并携带最终任务结果。前端 connectSse() 监听这两类事件,log 事件追加到时间线,done 事件更新任务状态并触发后续逻辑。
验证发布的流程完整体现了这套机制:
const handleVerify = async () => {
verifying.value = true
await verifyTask(taskNo) // 触发后端异步验证
connectSse((result) => { // 建立 SSE,完成时回调
if (result.status === 'APPROVED') {
verifyDialog.approved = true
verifyDialog.title = '验证通过'
verifyDialog.message = `词条「${result.entryName}」已通过百度百科审核并正式上线!`
verifyDialog.url = result.entryUrl || ''
} else {
verifyDialog.title = '尚未通过'
verifyDialog.message = `词条「${result.entryName}」尚未通过审核,请稍后再试。`
}
verifyDialog.visible = true
})
}
点击按钮 → 后端 Playwright 打开浏览器搜索词条 → SSE 推送结果 → 弹窗显示。如果已通过,弹窗里是绿色的大勾图标加词条链接;如果未通过,是橙色警告图标加提示文案。整个交互闭环,不刷页面,不轮询接口。
截图:任务详情页执行步骤时间线(运行中状态) 截图:验证发布结果弹窗(已通过/未通过两种)

五、账号管理------弹药库清点
AccountList.vue 是系统的后勤保障页面。
每个百度账号对应一行:账号名、状态(ACTIVE/DISABLED)、每日用量(dailyUsed / dailyLimit)、最后使用时间、Cookie 有效期。
操作列两个按钮:
- 禁用/启用:ACTIVE 状态显示橙色"禁用",DISABLED 状态显示绿色"启用",点击切换
- 重置 Cookie:清除该账号的 Cookie 缓存,下次任务执行时重新走账号密码登录流程
右上角「添加账号」按钮弹出对话框,填入百度用户名和每日发布限额(默认 20)即可。密码不在这里录入------系统读取环境变量,账号密码绝不落数据库。
任务创建时,「指定账号」下拉的选项来自这个页面管理的数据,并实时显示用量 (3/20) 这样的格式,让运营同学一眼看出哪个账号还有余量。
截图:账号管理页面

六、一场没有硝烟的 Bug 战------词条已存在
前后端联调期间,遇到了一个隐蔽的边界情况:词条已存在时该怎么办?
最初的代码逻辑是:搜索 → 发现词条不存在 → 点击"创建词条"按钮 → 等待页面跳转 → 填写内容。但实际运行中,有些词条明明搜索时显示"不存在",点了创建之后,百度却返回了一个错误页面,window._tplData 里赫然写着 errno: 110004------词条已存在。
更诡异的情况是「待评审」弹窗:词条虽然还没上线,但已有人提交了审核稿,此时百度会弹出一个 .main-message 浮层提示"暂不支持添加"。这个弹窗不会让页面跳转,Playwright 就这么傻等着,最终超时失败。
三层检测机制应运而生:
// 1. 等待 2 秒,检测 .main-message 弹窗(待评审情况)
try {
page.waitForSelector(".main-message", new Page.WaitForSelectorOptions()
.setTimeout(2000));
throw new EntryAlreadyExistsException("检测到待评审提示");
} catch (TimeoutError ignored) { }
// 2. 等待页面离开 createindex
page.waitForURL(url -> !url.contains("/page/createindex"), timeout);
// 3. 检测 110004 错误码(已存在错误页)
String pageContent = page.content();
if (pageContent.contains("110004") || pageContent.contains("Lemma/SubLemma Exist")) {
throw new EntryAlreadyExistsException("词条已存在(110004)");
}
捕获到 EntryAlreadyExistsException 后,BaikeService 不再走后续的填写、提交流程,而是直接调用 verifyEntry() 搜索获取词条现有链接,将任务标记为 SUBMITTED 并推送 SSE 完成事件,干净退出。
这一仗的弹药是从百度百科返回的 HTML 里扣出来的------把真实的页面源码贴给 Claude,它帮你定位 selector 和错误码,比猜强一百倍。
七、提交结果的最后一公里
还有一个细节,差点让整个发布流程功亏一篑。
词条提交成功后,Playwright 会跳转到一个 submitSuccessful 页面。这个 URL 是临时的,没有 /item/ 词条路径,存下来也没用。真正的词条 ID 藏在页面的 JavaScript 变量里:
window._tplData.data.lemmaId // 词条 ID,新词条还未上线时为 0
window._tplData.data.lemmaTitle // 词条名称
于是提交逻辑改为:
if (currentUrl.contains("submitSuccessful")) {
Object lemmaId = page.evaluate("() => window._tplData?.data?.lemmaId");
Object lemmaTitle = page.evaluate("() => window._tplData?.data?.lemmaTitle");
if (lemmaId instanceof Number && ((Number) lemmaId).longValue() > 0) {
return "https://baike.baidu.com/item/"
+ URLEncoder.encode(lemmaTitle.toString(), UTF_8)
+ "/" + ((Number) lemmaId).longValue();
}
return null; // 新词条尚未分配 ID,等待审核后验证
}
新建词条提交时 lemmaId 通常是 0------词条还没过审,百度还没分配 ID。这种情况返回 null,前端显示"-",等用户后续手动点「验证发布」确认上线。编辑现有词条时 lemmaId 有值,可以直接拼出真实链接。
八、打法总结:让 AI 当你的弹药补给兵
这个项目前后端加起来大约两周的零散时间,实际编码工时估计不超过三四天。Claude Code 在其中扮演的角色,不是"帮你写代码"这么简单,而是:
1. 侦察兵:不知道某个 HTML 结构用什么 selector?截图或粘贴源码,立刻给出答案。
2. 弹药生成器:样板代码、重复模式、CRUD 接口------批量生产,无需手写。
3. 错误分析官:Playwright 超时、Spring 事务嵌套报错、Vue 响应式失效------把报错丢进去,大概率直接给出根因和修复方案。
4. 架构审稿人:SSE vs WebSocket 的选型,状态机节点的设计,异常类的继承关系------讨论比查文档快得多。
当然,AI 也有它的边界。选择器不对时,它需要你提供真实的页面 HTML;逻辑边界的权衡,最终拍板的还是你。这不是一把全自动步枪,更像一挺需要你来瞄准的马克沁------但只要你瞄准了,弹雨自然倾泻而出。
后续计划:定时任务批量发布、Cookie 失效自动重登、多词条队列管理。战场还在延伸,弹药还在装填。