news 2026/10/6 18:31:08

Java+Vue构建区块链电子投票防篡改系统:架构与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java+Vue构建区块链电子投票防篡改系统:架构与实战

简介:基于Java与Vue的区块链电子投票与防篡改系统设计与实现完整项目实例,面向具备Java和Vue开发基础的软件工程师、全栈开发者、区块链技术爱好者及电子政务、数字治理、信息安全领域技术人员,解决高可信投票中的数据防篡改、过程透明和可追溯问题。包体为1个docx文档,整体约90KB,内容以设计方案与代码详解为主,涵盖项目背景与目标、技术架构、核心功能模块、数据库设计、前后端分离通信、智能合约计票及部署方案,并包含区块类与区块链类结构、SHA-256加密工具类、投票数据模型、身份认证流程、Vue前端投票组件等关键代码示例。目前已有80人学习浏览。通过这份实例,读者可掌握区块链与Java、Vue主流技术栈的融合思路,理解去中心化投票系统在防篡改、判重、自动计票、安全防护与数据一致性方面的实现细节,并为学校选举、社区自治、企业股东会等场景提供可二次开发的开源模板。

1. 电子投票为什么需要区块链做“存证层”:Java+Vue方案值不值得跟

后台管理员改了票数,前端页面看不出任何异常——这不是数据被“黑”了,而是数据库里那条记录被悄无声息地覆盖了。区块链基于Java+Vue的电子投票防篡改系统,核心思路是:每一张票都算出一个不可逆的哈希证据,再把前后票据按顺序串成一条链。后端Java负责出块、校验和业务事务,前端Vue负责把链的状态做成可视化的GUI管理台。这套方案适合课程设计、毕业设计,也适合中小规模内部选举的工程原型。它解决的核心问题不是“投票流程”,而是“票被改了能不能立刻被发现、甚至根本改不动”。这篇笔记从零讲清楚架构、代码、数据库和验证方法,照着搭一次,你就知道这条链到底拦住了什么。

2. 先立骨架:区块链存什么、Java与Vue各管哪一块

2.1 把防篡改拆成三层:数据库只是读加速,链才是真相

很多初次接触这个题目的人会犯一个方向性错误:试图把选票明文、候选人信息全部塞进区块链。实际上电子投票系统的“链”不需要承载所有数据,它只承载“凭证”。我的落地方案分三层:业务层、账本层、展示层。业务层是Java写的投票接口、候选人管理、选举场次控制;账本层是一条私有的简化区块链,每个区块里存的是某一批选票的哈希摘要;展示层是Vue做的GUI,让管理员能查看链上记录和校验结果。

为什么数据库和链并行存在?因为区块链本身不适合模糊查询和统计。你要快速算出某个候选人的票数,直接对数据库做COUNT(*)远比遍历区块更实际。数据库在这里扮演“读加速器”和“业务回滚记录”的角色,真正裁决数据是否被篡改的,是链上的哈希序列。数据库里任何一条选票记录被改动,只要与区块中保存的摘要对不上,verifyChain就能找出断点。这也是整个系统防篡改的立身之本:

提示:链上不存明文选票,只存“选票哈希 + 区块头的关联”。这样既节省链空间,又不会把选民隐私做成公开账本。

2.2 区块与哈希链核心字段:previous_hash、timestamp、data一个都不能少

区块链之所以“链”得起来,靠的是每个区块头里的previous_hash字段。这个字段指向上一区块的哈希,而当前区块的哈希又由自身数据内容计算而来。一旦有人改了第3个区块的data,第3个区块的hash就会变化,第4个区块还带着旧的第3块哈希,断链就出现了。即便攻击者把第4、5、6块全部重算一遍,也会在整体校验的“重放攻击”面前暴露:所有区块的时间戳、出块顺序和业务签名都会留下矛盾。

一个最小可运行的区块字段设计如下:

字段类型作用
indexint区块高度,从0开始递增
timestamplong出块时间,毫秒级
datatext选票记录的哈希摘要或JSON
previousHashvarchar(64)上一区块的SHA-256
hashvarchar(64)当前区块完整哈希
nonceint工作量证明计数,控制出块成本

hash的计算一般是对index + timestamp + data + previousHash + nonce做 SHA-256。注意这里的顺序必须全局一致,否则同一份数据在不同机器上会算出完全不同的哈希。data字段建议存放对多张选票做“二次哈希”后的摘要,比如把一批选票的文本按固定顺序拼接,再整体做SHA-256,这样链上每个区块可以代表一批次投票,而不是一票一区块。

2.3 Java+Vue+GUI选型理由:为什么这套组合适合投票业务

用Java写后端,不是因为区块链只能配Java,而是投票这个业务场景对事务、并发和工程化要求很高。Spring Boot天然提供声明式事务,投票接口需要保证“重复投票校验”和“区块写入”的原子性;Java的MessageDigest直接支持SHA-256,不需要引入额外密码学依赖;Maven把打包、测试、部署流程统一,后续接定时任务做链与数据库对账也很顺手。

Vue则解决了GUI展示的问题。管理端需要实时看到当前链高度、最新区块哈希、校验是否通过,这类数据刷新用Vue的响应式状态再合适不过。Element Plus组件库可以快速生成候选人管理表格、投票进度条、异常告警卡片;Vue Router负责投票页、区块浏览器、系统设置三个路由页面的切换。这套GUI不是花架子,它承担了一个关键职责:让非技术背景的监票人也能看懂“系统没有被篡改”,而不是靠一组命令行去说服别人。

至于为什么不选Node或Go:Node写后端原型快,但涉及多数据源事务和复杂报表时,Java生态的成熟度明显更高;Go并行能力出色,但团队成员如果以Java为主,学习成本会直接拖慢项目。结合热词里常见的 “vue安装及环境配置”“java面试题”这些搜索诉求,这套组合还有一个隐形优势:前端Vue、后端Java都是目前就业市场最主流的技术栈,做完这个项目,简历上的“区块链”落点也是实打实的工程经验,不是纸面概念。

3. Java后端:SHA-256出块、投票接口与链完整性校验

3.1 区块类与计算哈希:先用最小Java代码把区块“立”起来

不用任何第三方区块链框架,用JDK自带的MessageDigest就可以实现核心逻辑。先定义区块类:

public class Block { private int index; private long timestamp; private String data; private String previousHash; private String hash; private int nonce; public Block(int index, long timestamp, String data, String previousHash) { this.index = index; this.timestamp = timestamp; this.data = data; this.previousHash = previousHash; this.nonce = 0; this.hash = calculateHash(); } // 注意:所有参与哈希的字段顺序必须固定 public String calculateHash() { String raw = index + timestamp + data + previousHash + nonce; return SHA256Util.sha256(raw); } public void setNonce(int nonce) { this.nonce = nonce; this.hash = calculateHash(); } // getter方法略 }

哈希工具类单独放,避免在业务代码里重复写字节转换逻辑:

public class SHA256Util { public static String sha256(String input) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }

这段代码的关键参数是String raw里的拼接顺序,我第一次做的时候把nonce放在了previousHash前面,结果同一份数据在多线程并发出块时哈希值不稳定。原因不是随机数,而是拼接顺序不一致导致相同内容算出不同摘要。setNonce里每次改动都要重新算hash,这是工作量证明的入口,之后挖矿逻辑才能生效。

3.2 投票接口与出块联动:先入链,后落库

投票的完整流程不是“先写数据库再算哈希”,而是“先构造选票摘要,出块成功后再落库”。倒过来做会出现一个经典问题:数据库事务回滚了,区块链上却留下了票,导致两边不一致。出块方法用简化工作量证明,难度值设为4,要求哈希前四位是0:

public Block mineBlock(String voteData) { Block prev = chain.get(chain.size() - 1); Block newBlock = new Block( prev.getIndex() + 1, System.currentTimeMillis(), voteData, prev.getHash() ); String target = "0000"; while (!newBlock.getHash().substring(0, 4).equals(target)) { newBlock.setNonce(newBlock.getNonce() + 1); } chain.add(newBlock); return newBlock; }

调用方投票接口把“选举id + 选民id + 候选人id + 时间戳”拼成一个确定性的JSON字符串,再传给mineBlock。这里有个细节:JSON的字段顺序要按字母排序,避免同一个票在不同语言序列化后内容不一致。投票接口的事务逻辑这样写:

@Transactional public VoteResult castVote(VoteRequest req) { // 1. 重复投票校验 int count = voteRecordMapper.countByVoter(req.getElectionId(), req.getVoterId()); if (count > 0) { throw new BusinessException("该选民已投过票"); } // 2. 构造链上数据,先出块 String voteData = buildVoteData(req); Block block = blockchainService.mineBlock(voteData); // 3. 链上成功后再落库 VoteRecord record = new VoteRecord(); record.setElectionId(req.getElectionId()); record.setVoterId(req.getVoterId()); record.setCandidateId(req.getCandidateId()); record.setBlockIndex(block.getIndex()); record.setBlockHash(block.getHash()); voteRecordMapper.insert(record); return new VoteResult(block.getIndex(), block.getHash()); }

这里的buildVoteData内部用JSONObject并开启ordered=true,保证字段顺序稳定。difficulty=4在普通电脑上大约需要几十到几百次nonce尝试,单次投票延迟在毫秒级,不会给用户带来明显卡顿。如果选现场实时投票,建议把难度保持在3到4之间,5个前导零会让出块时间跳到秒级,投票高峰期体验会变差。

3.3 链完整性校验:把篡改检测做成可复用的方法

防篡改系统最关键的方法是verifyChain。它做两件事:第一,检查每个区块自身的hash是否与重新计算结果一致;第二,检查相邻区块的previousHash与上一块的hash是否相等。这个方法单独放在BlockchainService里,方便被定时任务、启动自检和Vue后端的接口调用。

public VerifyResult verifyChain() { List<Block> blocks = chain.getBlocks(); if (blocks.size() <= 1) { return VerifyResult.valid(); } for (int i = 1; i < blocks.size(); i++) { Block current = blocks.get(i); Block previous = blocks.get(i - 1); // 自身哈希是否有效 String calcHash = current.calculateHash(); if (!calcHash.equals(current.getHash())) { return VerifyResult.invalid(i, "block hash mismatch"); } // 是否指向上一块 if (!current.getPreviousHash().equals(previous.getHash())) { return VerifyResult.invalid(i, "previous hash broken"); } // 校对数据库里的选票哈希是否与区块data摘要一致 String dbDigest = voteRecordMapper.selectBatchDigest(current.getDataHash()); if (dbDigest != null && !dbDigest.equals(current.getData())) { return VerifyResult.invalid(i, "db data mismatch"); } } return VerifyResult.valid(); }

第三层校验很容易被忽略,但它才是真正把“链”和“业务数据库”绑起来的锁。仅检查前两站只说明区块链内部没断,并不能说明数据库里的票数没被改。实际中要在voteRecord表里维护一个batch_digest字段,定时按区块范围聚合选票哈希。如果数据库被恶意更新,聚合摘要立即对不上,系统就能标记出具体是哪个区块范围内的选举数据异常。

注意:calculateHash()里必须读的是区块对象当前字段值,不能读数据库里保存的旧hash。很多翻车案例都是因为把hash字段写进了计算源,导致算来算去等于自己,校验形同虚设。

4. Vue前端与GUI落地:投票页、区块浏览器与篡改标红

4.1 用Vue Router搭页面骨架:管理后台的三个核心页面

前端部分我用Vue 3 + Element Plus。整个GUI不需要太复杂,三个页面足够:投票操作页、链状态页、异常审计页。Vue Router做路由守卫,判断用户是否登录;后端接口返回的链校验状态存在一个全局store里,页面切换时不会重新拉取。

// router/index.js const routes = [ { path: '/vote', component: VotePage, meta: { title: '投票' } }, { path: '/chain', component: ChainBrowser, meta: { title: '区块浏览器' } }, { path: '/audit', component: AuditPage, meta: { title: '篡改审计' } } ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); } else { next(); } });

这里路由守卫的作用不只是拦未登录用户,更重要的是在进入审计页之前,动态校验一次链上数据。我习惯在AuditPage的beforeRouteEnter里主动调用后端校验接口,避免展示过期状态。

4.2 axios封装与链状态轮询:5秒刷新一次后端防篡改结果

区块浏览器需要实时显示当前链高度和最新区块hash。Vue侧用setInterval轮询后端/api/chain/state接口,轮询间隔建议5到10秒。太短会给后端造成无谓压力,太长又会让人觉得GUI不“实时”。

// api/index.js import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 8000 }); // 请求拦截器注入token service.interceptors.request.use(config => { config.headers.token = localStorage.getItem('token') || ''; return config; }); // 响应拦截器统一处理错误码 service.interceptors.response.use( res => { if (res.data.code === 200) return res.data; return Promise.reject(new Error(res.data.msg)); }, err => Promise.reject(err) ); export const getChainState = () => service.get('/chain/state');

链状态页面的核心组件里,轮询一定要在beforeDestroy或onUnmounted里清理。很多Vue项目出现“切走页面后接口还在刷”的现象,就是定时器没有销毁,导致内存泄漏和无效请求。

<template> <div> <el-alert :title="chainValid ? '链状态正常' : '检测到数据被篡改!'" :type="chainValid ? 'success' : 'error'" /> <el-table :data="blocks" v-loading="loading"> <el-table-column prop="index" label="区块高度" width="80" /> <el-table-column prop="hash" label="区块哈希" show-overflow-tooltip /> <el-table-column prop="previousHash" label="前序哈希" show-overflow-tooltip /> </el-table> </div> </template> <script> import { getChainState } from '../api'; export default { data() { return { blocks: [], chainValid: false, loading: false, timer: null }; }, mounted() { this.loadData(); this.timer = setInterval(this.loadData, 5000); }, beforeDestroy() { if (this.timer) clearInterval(this.timer); }, methods: { async loadData() { this.loading = true; try { const res = await getChainState(); this.blocks = res.data.chain; this.chainValid = res.data.valid; } finally { this.loading = false; } } } }; </script>

4.3 篡改检测的可视化交互:结果下钻与红色告警

当verifyChain返回无效时,后端接口会附带断点信息,比如“第7块哈希不匹配”或“第7块与第8块previous_hash断开”。前端审计页要把这些信息展示成可定位的问题列表。

我采用的做法是:表格列出自检结果,每行显示“区块高度、异常类型、修复建议”。“修复建议”不是让用户手动改数据库,而是展示一条可执行的恢复指令,例如“从备份库恢复第7块对应的选票批次,并重新出块”。这个设计在实际演示中很加分,观众不再只看红色告警,还能理解接下来该做什么。

<template> <div> <el-table :data="errors"> <el-table-column prop="index" label="异常区块" width="120" /> <el-table-column prop="type" label="异常类型" width="160" /> <el-table-column prop="suggestion" label="修复建议" /> </el-table> </div> </template>

GUI的意义不只是好看。投给候选人之后,管理员和监票人都需要能独立验证系统没有被动手脚。一个标红的“篡改检测”图标,比十页技术文档更有说服力。

5. 数据库表设计、一致性对账与5个必踩的坑

5.1 四张核心表怎么建:选举表、候选表、票记录表、区块表

数据库是整个系统的“影子账本”,表结构不需要复杂,但约束要到位。我建议用四张核心表:

表名关键字段说明
electionid, name, status, start_time, end_time选举场次
candidateid, election_id, name, party候选人
vote_recordid, election_id, voter_id, candidate_id, block_index, block_hash, create_time选票记录,联合唯一约束
blockindex, timestamp, data, previous_hash, hash, nonce区块链数据落盘

vote_record表上一定要建联合唯一索引uk_election_voter (election_id, voter_id)。这是代码之外的兜底防线,即使并发请求同时通过重复校验,数据库唯一索引也能拦住第二条插入。

ALTER TABLE vote_record ADD CONSTRAINT uk_election_voter UNIQUE (election_id, voter_id);

同时建议给block表的index加唯一索引,防止并发出块时两个相同高度的区块被插入。链的data字段用TEXT类型即可,区块的哈希字段统一CHAR(64),固定长度比VARCHAR少一次长度判断。

5.2 一致性对账SQL与定时任务:让数据库和链定期“对表”

只靠接口触发校验是不够的,真正的生产环境需要定时对账。Spring Boot里用@Scheduled每分钟执行一次,把链上最后一个区块高度与数据库里vote_record的最大block_index做对比。如果数据库落后,说明有出块成功但落库失败或事务回滚;如果数据库领先,说明有数据绕过区块链直接写入了库表。

-- 找出数据库中不在链上的选票记录 SELECT vr.id, vr.voter_id, vr.candidate_id FROM vote_record vr LEFT JOIN block b ON b.index = vr.block_index WHERE b.index IS NULL;

定时对账发现异常后,不要自动修复,只记录到audit_log表并推送告警到GUI。自动修复容易把真实攻击和程序bug混在一起,人工判断后再恢复更稳妥。这也是区块链系统一个反直觉的要点:自动化程度越高,被攻击者利用的自动回滚入口就越危险。

5.3 避坑记录:从哈希拼接顺序到GUI轮询泄漏

第一条坑:哈希拼接顺序不一致。同一个投票数据,在Java那边字段顺序是index + timestamp + data + previousHash + nonce,在Vue前端或数据库校验脚本里如果换了顺序,算出的哈希完全不同。现象是链上校验偶尔报错,原因不是数据被篡改,而是校验程序本身的拼接方式和出块程序不一致。解决方法是把拼接规则固化成一个静态方法或SQL函数,所有调用方统一使用。

第二条坑:数据库回滚后区块链已经出块。投票接口如果先落库再出块,当数据库事务回滚时,选票已经上链,但库表里没有记录。以后逐票审计时,链上多了一张“幽灵票”。解决方式就是前面写的“先出块、后落库”,并且出块和落库必须在同一个事务边界内;落库失败时要补偿出块,把链上刚出的区块标记为无效。

第三条坑:整链重算绕过previous_hash校验。攻击者如果改了第3块的数据,然后把第3、4、5块全部重新算哈希,传统校验可能检查不出来,因为哈希链内部依然自洽。解决方式是在出块时附带系统签发的时间戳和随机盐,并且由独立审计服务定期验证“区块时间戳单调递增”“nonce与难度匹配”。只信任哈希链,却不验证出块成本和时间序列,是这个系统最容易被攻击者“抄近路”的地方。

第四条坑:GUI轮询定时器泄漏。Vue页面在beforeDestroy里没有清理定时器,页面切走后仍每隔5秒请求后端接口,导致服务器日志被刷爆,浏览器内存持续上涨。现象很隐蔽,只在长时间操作后台时出现卡顿。解决方式是用生命周期钩子统一清理定时器,或者直接用VueUse的useIntervalFn,它会自动跟随组件卸载停止执行。

第五条坑:calculateHash里把hash字段当成计算源。这个属于典型的黑匣子问题:某些初学者把当前区块已存储的hash拼进原始字符串,再算新hash,导致无论如何修改区块内容,计算出来的hash都和存储值保持一致,校验永远通过。正确的calculateHash只能基于业务数据和区块头字段计算,绝不能读自身的hash字段。

6. 上线前用“攻击演练”验证防篡改能力:三组命令测完整个系统

6.1 三组模拟攻击测试:改数据库、改区块、改哈希

防篡改系统上线前,我会按“攻击者视角”做三轮演练。第一轮,模拟管理员偷偷改一张选票的候选人id;第二轮,模拟攻击者直接修改区块链上的区块内容;第三轮,模拟攻击者同时重算目标区块和后面所有区块的哈希。这三轮覆盖了“只改库、只改链、整体重放”三类情况。

# 第一轮:改数据库选票,不改链 UPDATE vote_record SET candidate_id = 2 WHERE id = 100; curl -X POST http://localhost:8080/api/chain/verify # 第二轮:改链上区块内容,不改后续哈希 UPDATE block SET data = 'tampered-data' WHERE index = 7; curl -X POST http://localhost:8080/api/chain/verify # 第三轮:整链重算,模拟最高风险攻击 # 此时需要结合时间戳校验和nonce难度校验,不能只看previous_hash curl -X POST http://localhost:8080/api/chain/verify?deepCheck=true

第一轮和第二轮都会被verifyChain直接拦截,第三轮需要额外结合时间戳单调性校验和nonce重算验证。我自己第一次做项目时漏掉了第三层,觉得只要哈希链没断就安全。直到我用脚本重算了后面5个区块,发现校验接口返回“通过”,才意识到出块成本和时间戳才是防重放的底线。

6.2 验证指标与我的收尾习惯

上线前我习惯把校验结果固化成三个数字写进运维看板:链高度、最新区块hash的前8位、最后校验时间。每次演示前先跑一遍攻击演练,再打开GUI截图存档。这套习惯帮我避免过两次尴尬的演示翻车,一次是忘记清理Vue轮询导致接口超时,一次是hash拼接顺序不一致导致系统自检误报。

如果你把这个项目放在简历上,面试官大概率会追问“防篡改到底防住了什么,没防住什么”。最有说服力的回答不是讲共识算法,而是直接演示那三条攻击命令的结果:数据库被改会报警,区块被改会断链,整链重算因为时间戳和nonce校验也被拦截。唯一没防住的是物理破坏服务器,那已经超出软件系统能承诺的范围。

这套方案做下来,我的体感是区块链在这里不是炫技,而是给电子投票补上了最后一块“可信”的拼图。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 18:28:30

国产芯片选型方法论:从信息断层到方案级替代的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 18:27:36

Polar SI9000阻抗设计全流程:单端与差分信号计算实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 18:23:38

神经网络如何突破压缩感知图像重构的物理极限

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 18:19:54

低成本无线EEG原型链路:从脑电采集到网页实时显示

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 18:18:30

汽车以太网线束测试避坑指南:TC2与TC9标准差异及设备选型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 18:18:30

从电磁感应到耳机改造:动圈麦克风原理与DIY实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华