☰
区块链存证系统前端实战:从哈希指纹到上链验证
2026/10/27 8:55:17 网站建设 项目流程

简介:面向计算机专业学生与区块链技术初学者的电子证据存证系统前端源码包,可用于毕业设计、课程设计及项目立项演示。项目基于区块链去中心化、不可篡改特性,实现电子证据上传、存证、查询等核心交互,前端界面简洁易用,帮助用户快速理解存证业务逻辑。压缩包共78个文件,大小5.13MB,以JavaScript逻辑代码为主(37个js),辅以17个less样式文件和css完成页面布局,jpg/png图片资源提供界面素材,同时包含eslintrc、prettierrc、editorconfig等工程化配置文件,便于规范开发与二次修改。资源还附带README及说明文档,可学习到现代Web前端技术栈与区块链应用结合的具体实践。目前已有61人学习/下载,内容紧凑、目录结构清晰,对正在做区块链相关设计或入门区块链前端开发的人群有一定参考价值。通过研读源码,可掌握存证流程中数据封装、接口调用与密钥交互等环节的代码实现。

1. 区块链电子证据存证系统前端:先把“链上只存指纹”这条线想清楚

基于区块链的电子证据存证系统前端源码,核心价值不是把页面做得好看,而是把证据文件在用户设备上转成一段链上可核验的哈希指纹。想象一个实际场景:律师收到一份合同扫描件,需要立即取证,传统做法是公证处全程录像;有了存证系统,文件在前端算出 SHA-256 摘要,摘要随交易信息写入链上,之后任何人拿同一份文件重新计算摘要,并对比链上记录,就能判断文件是否被改过。

这套源码适合正在做区块链毕设的学生,也适合公司里需要快速搭存证 POC 的前端。它的重点不在 UI 动效,而在哈希计算、上链参数组装、验证回查这一段逻辑,这几个点也是面试时最容易被追问的地方。先给一个反直觉结论:这种系统里最容易被看轻的前端层,恰恰是整个信任链的第一道关口。如果前端算摘要这步出现偏差,后端和链上做得再严谨也没有意义,因为用户无法确认链上摘要到底对应的是不是自己手上这份文件。

2. 前端与区块链的数据边界:哈希指纹、安全上下文与接口契约

2.1 一条数据流里的前端职责:链上只存摘要,不存文件

区块链存储成本高、出块大小有限制,不适合直接存放合同扫描件、视频、聊天记录这类原始文件。常规设计是链上只保存“证据文件的哈希摘要 + 存证元数据 + 交易哈希”,文件本体存在业务后端或用户自己手里。既要能证明文件在某个时间点存在过,又不必把隐私内容公开上链,这是存证系统的基本信任模型,也决定了前端的职责边界。

前端在整个链路上做三件事:第一,在用户设备上计算证据文件的哈希指纹;第二,把指纹和证据名称、文件大小、描述、案件编号等元数据一起提交给后端,由后端组装交易并上链;第三,拿回存证编号、交易哈希、区块高度、区块时间,在界面上展示并支持验证。这里面最微妙的地方是“指纹必须在浏览器端算”,如果文件先传到后端再算哈希,用户没法确认算的是不是自己手上这份文件。在前端算,用户能亲眼看见文件选择与摘要生成发生在同一台设备上,证据完整性的解释成本会低很多。

从证据效力角度看,电子证据通常要求真实性、完整性、关联性与合法性。前端能直接保障的是完整性和取证时的即时性:文件一旦被打开并计算指纹,之后的任何改动都会导致哈希不一致。把这条边界划清楚后,后续模块设计就不会跑偏成“把整个文件 post 给后端让后端算 hash”,那种做法把前端变成纯展示层,存证的可信度会明显下降。常见做法是把前端定位成“取证终端”,后端定位成“上链代理”,节点定位成“存证账本”,三层各管一段。

2.2 用 Web Crypto API 算文件哈希:浏览器原生指纹计算

算指纹优先用浏览器原生的crypto.subtle.digest,不要一上来就引 crypto-js。原生 API 由浏览器底层加密模块实现,性能比纯 JS 实现好,还不增加包体积。下面这段是这套源码里最核心的工具函数:

// src/utils/hash.js // 把 ArrayBuffer 转成十六进制字符串,方便写入链上 function bufferToHex(buffer) { const bytes = new Uint8Array(buffer) let hex = '' for (let i = 0; i < bytes.length; i++) { hex += bytes[i].toString(16).padStart(2, '0') } return hex } // 计算文件的 SHA-256 指纹 export async function calcFileHash(file) { const arrayBuffer = await file.arrayBuffer() const digest = await crypto.subtle.digest('SHA-256', arrayBuffer) return bufferToHex(digest) }

calcFileHash接收一个 File 对象,内部先用file.arrayBuffer()把整个文件读进内存,再交给crypto.subtle.digest计算 SHA-256。函数返回 64 位十六进制字符串,比如e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,这个字符串就是后续要提交给后端上链的 contentHash。

需要注意两点。第一,crypto.subtle只在安全上下文中暴露,也就是 HTTPS 或 localhost,HTTP 内网环境会出现 undefined,这个坑在第五章会单独展开。第二,file.arrayBuffer()会把整个文件加载进内存,对于几 GB 的视频会有压力,大文件场景需要改用分片计算,方法在第四章给出。选择 SHA-256 而不是 MD5,是因为证据场景对碰撞安全性有要求,MD5 已经不被推荐用于这类需要长期保存的司法证据链路。

2.3 技术栈选型:Vue3、Vite、Element Plus 各自解决什么问题

这套前端源码用的是 Vue3 组合式 API、Vite 构建、Pinia 做状态管理、Element Plus 做 UI 组件。选这套组合不是因为追新,而是存证系统的页面结构高度集中在上传、表格、表单、详情、验证结果这几类中后台场景,Element Plus 能直接提供现成的上传组件、进度条、弹窗和表格,省掉大量样式重复工作。

模块选型理由
视图层Vue3 组合式 API逻辑复用方便,哈希计算、验证逻辑可以抽成 composable
构建工具Vite开发服务器启动快,迭代电子证据页面时编译速度快
状态管理Piniastore 结构清晰,存证台账和案件筛选条件适合全局共享
UI 组件Element Plus上传、表格、表单、步骤条开箱即用
请求层axios 封装统一处理 token、错误码、loading 状态

Vite 在这类项目里收益尤其明显,因为存证页面上传组件和表格组件占多数,每次改样式或加字段都要即时调试,Vite 冷启动和热更新速度比 Webpack 时代舒服得多。Pinia 适合保存“当前案件编号”、“待验证证据列表”这类跨页面状态,切换详情页时不用重复请求。请求层用一个src/utils/request.js统一封装 axios,后端返回统一结构{ code, data, message },前端只在业务层处理数据,不散落处理 HTTP 错误。

3. 存证模块落地:从选择文件到拿到交易哈希的代码细节

3.1 三个后端接口的最小契约:存证、查询、验证

按照上一章的数据边界,后端只暴露存证、查询、验证三个核心接口,不暴露链节点 RPC。前端直连区块链节点的问题在于,RPC 地址和签名私钥都会暴露在浏览器端,等于把链上账户权限交给了所有访问页面的人,所以必须由后端代理上链。

接口方法请求参数返回关键字段
创建存证POST /api/evidence/saveevidenceName、contentHash、fileSize、description、caseId、requestTimeevidenceId、txHash、blockNumber、blockTime、status
存证列表GET /api/evidence/listpage、pageSize、caseId、statusrecords、total
链上验证GET /api/evidence/verifyevidenceIdcontentHash、txHash、blockNumber、blockTime、verified

返回里的blockNumber是交易被打包的区块高度,blockTime是区块时间,这两个字段是凭证里最核心的内容。特别说明一下requestTime,前端提交时带一个本地时间戳,它只作为操作记录保留,不做正式凭证时间,正式时间以后端返回的区块时间为准。这样设计是避免用户改电脑时间伪造取证时间,区块时间由链节点统一决定,前端改不了。

3.2 提交存证的可运行代码:前端算完哈希后如何组装参数

页面流程是:用户选择文件 → 前端计算哈希 → 用户填写证据名称和描述 → 点击提交 → 后端上链后返回存证凭证。提交函数如下:

// src/api/evidence.js import request from '@/utils/request' // 前端计算好哈希后,把摘要和元数据提交给后端上链 export function saveEvidence(data) { return request.post('/api/evidence/save', { evidenceName: data.name, contentHash: data.hash, fileSize: data.fileSize, description: data.description || '', caseId: data.caseId || '', requestTime: Date.now() }) }

这个函数把data.hash放到contentHash字段,后端拿到后直接组装一笔交易写入链上。fileSize传 number 类型,单位是字节,后端可以用于校验文件大小策略,也能在列表页做展示格式化。caseId是可选的案件编号,如果没有就传空字符串,后端不会因为没有案件编号拒绝上链。requestTime使用Date.now()生成,但如上所述,它不会成为凭证上的正式时间。

调用前建议先做一次前置校验:文件名非空、文件大小不超过后端限制、data.hash符合 64 位 hex 格式。校验不过直接弹提示,避免把明显非法的请求发给后端。上链本身是异步过程,有的后端接口是同步返回交易哈希,有的则是先创建存证记录再异步上链,前端需要根据status字段区分“已上链”和“待确认”两种状态,不要假设点击提交后立刻就出现区块高度。

3.3 证据列表与案件关联:列表页字段如何组装

列表页是用户每天看得最多的页面,它不能直接拿后端原始字段铺开,原始字段里有完整哈希和完整交易哈希,表格塞不下。通常要做一层字段转换:

// src/views/evidence/list.vue 中的行数据组装 function buildRow(item) { return { id: item.evidenceId, name: item.evidenceName, hash: shortHash(item.contentHash), size: formatSize(item.fileSize), caseId: item.caseId || '-', txHash: shortTx(item.txHash), blockHeight: item.blockNumber, blockTime: formatTime(item.blockTime), statusText: item.status === 1 ? '已上链' : '待确认' } } function shortHash(hash) { if (!hash) return '-' return `${hash.slice(0, 8)}...${hash.slice(-8)}` }

shortHash只展示前后各 8 位,鼠标悬停时再显示完整哈希。formatSize把字节数转成 KB、MB、GB 的友好描述,formatTime把时间戳转成YYYY-MM-DD HH:mm:ss。这样表格列不会爆炸,用户又能快速定位到具体证据。

案件关联在列表里体现为caseId字段。当一个案件下挂多个证据时,列表页支持按caseId筛选,详情页再对同一案件下的多个证据执行批量验证。常见的业务习惯是律师以案件为单位整理证据包,每份材料单独存证、统一归档。前端只需要把caseId传给后端,由后端在查询时过滤即可。这种结构也为后面做批量校验留好了入口,证据列表多选后,就能把选中的文件拖进验证页。

4. 链上验证与凭证导出:让证据可回查、可带走

4.1 验证链路:重新计算哈希并与链上记录比对

验证页是存证系统里最能体现可信度的页面。用户拖入一份文件,选择或输入存证编号,前端重新计算文件哈希,再从后端拿到链上保存的哈希做比对。核心判断逻辑如下:

// 验证:重新计算文件哈希并与链上记录比对 async function verifyFile(file, evidenceId) { const currentHash = await calcFileHash(file) const { data } = await getVerifyInfo(evidenceId) const onChainHash = (data.contentHash || '').replace(/^0x/, '').toLowerCase() const pass = currentHash.toLowerCase() === onChainHash return { pass, currentHash: currentHash.toLowerCase(), onChainHash, blockTime: data.blockTime, txHash: data.txHash } }

verifyFile返回一个对象,pass表示文件是否与链上摘要一致。比对前必须做规范化和大小写归一:去掉0x前缀、统一转小写,否则会出现两个内容完全相同的哈希因为前缀不同被判不通过的情况。currentHash是本地新算出来的指纹,onChainHash是链上记录的指纹,两者一致说明文件自存证以来没有被改动过。

验证通过后页面通常展示绿色结果卡,包含文件当前哈希、链上原始哈希、交易哈希、区块高度、区块时间。验证不通过时,要明确区分“文件被篡改”和“存证记录不存在”两种情况,前者展示哈希比对结果,后者提示用户检查存证编号是否正确。这里不要把data可能为空的情况漏掉,后端查不到记录时直接给出独立文案,避免界面报空指针错误。

4.2 存证凭证导出:把链上信息打包成 JSON

存证完成后用户需要一份可以保存、转发、打印的凭证。前端生成 JSON 文件是成本最低、兼容性最好的方式,它包含所有验证所需信息,任何人拿到这个 JSON 和原始文件就能重新发起验证。

// 将存证信息导出为 JSON 凭证文件 function exportCertificate(evidence, txInfo) { const payload = { evidenceId: evidence.id, fileName: evidence.name, contentHash: evidence.contentHash, txHash: txInfo.txHash, blockNumber: txInfo.blockNumber, blockTime: txInfo.blockTime, verifyUrl: `${location.origin}/verify?evidenceId=${evidence.id}` } const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = `${evidence.name}.evidence.json` link.click() URL.revokeObjectURL(link.href) }

verifyUrl字段很关键,它让接收方不用手动输入存证编号,点开链接直接进入验证页。JSON.stringify(payload, null, 2)格式化输出,方便人工检查内容。注意URL.revokeObjectURL要在 download 之后调用,否则浏览器可能无法触发下载。更好一点的做法是同时生成一个适合打印的 HTML 凭证页,但 JSON 是最小可用方案,先落地再扩展。文件命名建议用“证据名称 + .evidence.json”,避免多个证据导出互相覆盖。

4.3 大文件分片计算:避免浏览器内存被打满

大文件直接走file.arrayBuffer()会把整个文件读进内存,几百 MB 的视频就会让浏览器标签页崩溃。工程里的替代方案是按固定大小分片读取,每一片先算 SHA-256,再把所有分片哈希拼接后做第二次摘要,得到最终的 64 位指纹:

// 固定分片大小,前端与验证端必须保持一致 const CHUNK_SIZE = 2 * 1024 * 1024 // 2MB // 文本转哈希,用于拼接分片摘要后的二次摘要 export async function calcHashByText(text) { const buffer = new TextEncoder().encode(text) const digest = await crypto.subtle.digest('SHA-256', buffer) return bufferToHex(digest) } // 分片计算文件指纹,最终返回 64 位 hex 字符串 export async function calcHashByChunks(file) { let offset = 0 const chunkHashes = [] while (offset < file.size) { const slice = file.slice(offset, offset + CHUNK_SIZE) const buffer = await slice.arrayBuffer() const digest = await crypto.subtle.digest('SHA-256', buffer) chunkHashes.push(bufferToHex(digest)) offset += CHUNK_SIZE } return calcHashByText(chunkHashes.join(',')) }

这套方案每次只把 2MB 分片读进内存,内存占用曲线平稳很多。代价是它不是对整份文件直接算 SHA-256,而是对“分片哈希列表的摘要”再摘要,验证时必须用相同的 CHUNK_SIZE 重算。所以分片大小要写死在参数配置里,前后端共享同一份约定,不能一边用 2MB 一边用 2MB 以下的值。如果追求更严谨的结构,可以用 Merkle 树逐层哈希,但毕设和演示场景下,上述分片拼接方案已经具备可解释性,遇到追问也能讲清楚思路。

5. 避坑指南:前端存证最容易翻车的五个场景

5.1 本机运行正常,部署到 HTTP 环境后 crypto.subtle 却报了 undefined

现象:开发时 localhost 一切正常,点击“开始存证”能顺利算出哈希。部署到公司的 HTTP 服务器或校园内网后,控制台报crypto.subtle is undefined,后续上链流程完全走不动。

原因:Web Crypto API 只暴露给安全上下文。localhost被浏览器视为安全来源,但普通 HTTP 访问不是,所以只有部署环境出了问题,本地开发反而发现不了。

解决:给部署环境配置 HTTPS 证书,正式系统没有任何妥协空间。如果只是答辩演示,可以直接保持 localhost 运行,或者用内网机器的 HTTPS 端口做代理转发。不要在 HTTP 页面里去兼容crypto.subtle,退回到纯 JS 哈希库虽然能跑,但性能和安全性都弱一截,被问到时解释成本更高。

5.2 为了演示方便,把私钥写进前端源码

现象:代码里有一份keystore.js,里面存了测试私钥或助记词,前端直接调用链 SDK 签名交易。本地演示没问题,打包发布后也能跑。

原因:前端 JS 资源任何人都能拿到,webpack 打包产物里搜privateKey之类关键词就能找到。私钥一旦泄露,账户里的资产或存证记录都可能被伪造。

解决:前端永远不存私钥,只持有链上地址。所有签名操作放到后端 Keystore 或硬件钱包中,前端通过业务接口发起上链,拿到txHash后只做展示。这是这套源码采用后端代理上链的根本原因,也属于存证系统开发里最典型的血泪经验:一旦私钥泄露,整个系统的可信度归零,之前所有存证记录都要作废重做。

5.3 上传几百 MB 视频,页面直接卡死

现象:用户拖入一个录像视频,文件还没传完,浏览器标签页就无响应,CPU 占用飙满,甚至崩溃白屏。

原因:file.arrayBuffer()把整个文件读进内存,再加上主线程同步做哈希计算,内存和 CPU 双双被打满。小文件没事,大文件问题暴露。

解决:文件大小超过阈值时改用第四章的分片计算逻辑,并把计算放到 Web Worker 线程中。前端在上传前先判断文件大小,超过 50MB 就提示预计耗时,同时进入分片路径。这一步做完,大文件存证不再影响页面点击操作,体验才算合格。

5.4 存证凭证上的时间戳用了浏览器本地时间

现象:生成凭证上印着2024-01-15 10:23:11,用户把它当作证据保存,结果链上区块时间显示的是另一个时间,两处对不上,被质疑存证真实性。

原因:前端把Date.now()直接当成存证时间展示,没有区分“本地操作时间”和“链上确认时间”。

解决:正式凭证只展示后端返回的blockTime,这是区块链节点确认交易时的出块时间,前端改不了。本地取证时间可以作为辅助字段记录,但不能作为凭证主时间。后端返回数据结构里保留requestTime和blockTime两个字段,前端展示时明确标注“本地取证时间”和“链上确认时间”,解释成本反而更低。

5.5 验证哈希时忽略前缀和大小写,同一份文件怎么都比不上

现象:用户拿同一份文件验证,页面总是提示“不通过”。把哈希打出来对比,发现链上记录带0x前缀,前端算的不带;或者一个是大写、一个是小写。

原因:不同链、不同 SDK 对哈希的展示习惯不一致,有的给0x,有的不给。字符串全等比较对大小写敏感,所以实际内容相同,比较结果却是 false。

解决:所有哈希在进入比对逻辑前都走一次 normalize 处理,去掉0x前缀、统一转小写、去掉首尾空白符。这个逻辑只写在工具函数里,不要在业务组件里各写一套,否则又会有人漏掉。第四章verifyFile里已经包含这段处理,新加验证入口时直接复用工具函数即可。

6. 批量校验的实战技巧:并发限制与 Web Worker 配合

存证系统做到后期,最常被问到的需求是一次性验证一批文件。律师手里一个案子几十份材料,逐份选择、逐份计算哈希会让用户失去耐心。批量校验的关键不是循环调用calcFileHash,而是把计算任务从主线程移出去,同时控制并发数,避免同时读取多个大文件再次把内存打满。

先把哈希计算放到 Web Worker 里,主线程只管调度:

// src/workers/hash.worker.js import { calcFileHash } from '../utils/hash' self.onmessage = async (event) => { const { id, file } = event.data try { const hash = await calcFileHash(file) self.postMessage({ id, hash, ok: true }) } catch (error) { self.postMessage({ id, ok: false, message: error.message }) } }

Worker 内部对每个文件计算哈希,通过id标识任务,避免同时计算多个文件时结果对不上。主线程创建一个调度器,负责给每个文件分配id并收集结果:

// src/utils/hashScheduler.js export function createHashScheduler() { const worker = new Worker(new URL('../workers/hash.worker.js', import.meta.url), { type: 'module' }) const pending = new Map() worker.onmessage = (event) => { const { id } = event.data const resolve = pending.get(id) pending.delete(id) resolve(event.data) } return { submit(file) { return new Promise((resolve) => { const id = crypto.randomUUID() pending.set(id, resolve) worker.postMessage({ id, file }) }) } } }

crypto.randomUUID()生成任务标识,pendingMap 保存每个任务的 resolve 回调,Worker 计算结果回来时按id找到对应回调并触发。这套结构允许主线程连续提交多个文件,Worker 会排队处理,不会阻塞 UI。

最后再套一个并发限制器,控制同时提交给 Worker 的文件数量:

// 批量任务并发限制:最多同时处理 limit 个任务 async function runWithLimit(tasks, limit = 2) { const results = new Array(tasks.length) let cursor = 0 async function workerLoop() { while (cursor < tasks.length) { const index = cursor cursor += 1 results[index] = await tasks[index]() } } const runners = Array.from({ length: Math.min(limit, tasks.length) }, () => workerLoop()) await Promise.all(runners) return results } // 用法:把待验文件逐个封装成异步任务 const scheduler = createHashScheduler() const tasks = files.map((file) => () => scheduler.submit(file)) const hashResults = await runWithLimit(tasks, 2)

runWithLimit内部维护一个游标,两个循环同时从任务队列里取任务执行,每个任务返回的 Promise 完成后继续取下一个。limit = 2意味着同时最多两个文件在 Worker 里算哈希,大文件场景下内存占用可控,小文件想提速可以调成 4。批量校验结束后,把每个文件的结果与链上哈希统一比对,再按案件维度汇总生成一份验证报告,整个流程就闭环了。

从那以后我每次接到批量存证需求,都会先看一遍文件大小分布,再决定limit取值:全是 PDF 和图片就设 4,有视频就设 2。部署前一定会在目标环境里跑一遍完整文件遍历,确认内存曲线没有异常才交给用户使用。这套处理方式让我少踩了很多线上卡死的坑,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询