项目 / PROJECT

BVT 节点销售平台

BVT 节点销售平台是该业务的 C 端入口,覆盖债券折价申购、BVT 转债券、质押、按日解锁与赎回排队。站点基于 React 19 + Vite 构建,10 条路由、5 种语言、约 2.2 万行;数据分别取自链上实时值与索引后的后端接口。

2026.09.15 独立前端开发
  • React 19
  • TypeScript
  • Vite
  • wagmi / viem
  • Ant Design

#项目背景

BVT 是一条以债券为核心的链上业务。用户以折价买入债券,折扣率随全网质押比例浮动;债券按持仓累计收益,到期可赎回本金;释放的 BVT 与 bBGT 可继续质押,退出时受每日赎回限额约束,只能进入队列按日等待。

这些状态全部记录在链上,且分散于多个合约:折扣率、APY 与发行总量、可领额度与解锁进度各在一处。合约只回答单点问题,而用户要做的判断 —— 此刻买入是否划算、今天能领多少、退出要等几天 —— 需要把这些单点答案拼成一条时间轴。平台承担的正是这层转换。

站点基于 React 19 + Vite 构建,10 条路由、5 种语言、约 2.2 万行。

#项目角色

独立承担该平台的全部前端开发:路由与页面、合约读写封装、数据分层、多语言资源、构建配置与四个环境的发布。

合约与索引服务由后端与合约侧提供,文中只在前端依赖其边界处提及。

#技术方案

#整体架构

分层依据「是否访问链」划分,越往下越不允许包含视图逻辑:页面负责编排,组件仅接收已解析字段,合约编码收敛于 contract/,读取时机决策收敛于 hooks/,跨页面共享状态收敛于 context/。不按页面划分的原因在于:同一批链上读取要服务多个页面 —— 首页的债券模块、/bond 交易页与统计页调用的是同一组合约方法,按页面切分会造成重复编码与口径漂移。

数据经两条链路进入:一条经 contract/ 直连链上,一条经 axios/ 调用后端索引服务。两条链路不合并,原因在于二者回答的问题不同 —— 链上回答「此刻是多少」,后端回答「排第几、属于谁」;后者依赖索引与遍历,链上不具备。结果在 context/ 中组装为页面所需结构,页面不感知数据来源。

BVT 节点销售平台整体架构:五层分工,仅 contract 一层访问链

#方案对比

选择替代方案理由
React 19 + TypeScriptVue 3与同期站点共用同一份脚手架,组件与 hooks 可直接复用
ViteWebpack四个环境通过 --mode 与四份 .env 切换,配置收敛于单一文件
wagmi + viem 的 readContractsethers 逐条 call批量读取为原生能力:一次编码、一次往返,无需自行维护 multicall 合约地址
Ant Design自研组件库表单、表格、弹窗需求密集且模式化,自研成本不划算
i18next + 五份手写 JSON 词条接入翻译管理平台词条由业务侧直接维护,无需翻译流程与外部服务;语言资源随包加载,切换不产生请求

#核心业务

首页(/):债券、BVT 兑换、社区与销售数据聚合为一页,承担新用户从了解规则到进入交易页的引导。

债券(/bond):核心交易页,买入前实时取得折扣率与单次可买上限;APY 与发行总量读链上,销售进度读后端。

BVT 转债券(首页兑换模块、/bond/burn-token):以 BVT 折价换取债券额度,操作入口与记录页分离。

质押(/stake、/bbgt):质押 BVT 或 bBGT 生息,年化与累计收益同屏;退出受每日限额约束,超出部分进入队列等待。

每日解锁(/unlock):锁仓按日线性释放,页面以可领、已领、剩余、已过天数四个数组织。

资产与账户(/bvt、/swap、/account):持仓视图、站内兑换,以及个人等级与邀请关系。

统计与赎回(/stats):全站销售与解锁数据;赎回队列位于此页,可在同一目标日内调整两条记录的先后。

#难点与解决方案

#难点一:赎回队列支持顺序改写

问题:链上仅返回「已申请数量」(GetUserRedeemRequests 给出数量与请求日期),不提供遍历接口,无法获知当前排位与前方人数。队列只能由后端按目标日分页提供,每行附带 queue_index 与 estimated_days。该列表还支持将两条记录互换位置 —— 对应链上 SwapQueueItem(a, b),一次交换一对。

约束全部位于前端:单次最多选择两条、等待不足 16 天的行不可选、仅可在同一目标日的查询结果内选择。任一约束未生效,用户签署的即为一笔必然失败的交易,而钱包仅返回「失败」提示。

解决方案:将约束收敛至选择状态,而非分散于按钮的点击事件。

const onChange = (row: RedemptionQueue) => setSwap(prev => {
  if (prev.some(i => i.queue_index === row.queue_index)) {
    return prev.filter(i => i.queue_index !== row.queue_index) // 再次点击取消选择
  }
  return prev.length >= 2 ? [...prev.slice(1), row] : [...prev, row] // 超出两条时替换最早选择
})

<Checkbox disabled={row.estimated_days < 16} onChange={() => onChange(row)} />

切换日期时清空已选项并重新拉取,避免跨查询残留;确认弹窗仅接受恰好两条,发交易前再次校验。

结果:互换操作仅有「选择两条 → 确认」一条路径,前端不可能发出越界请求。至于期间链上队列被第三方修改,前端无法拦截 —— 这类失败不会在发起前被发现,只在回执阶段以失败态呈现,由统一的交易通知收尾(复位按钮、弹窗置为失败),具体原因交由区块浏览器核对。

#难点二:解锁类页面需同时处理批量读与批量写

问题:单个地址可能持有多笔锁仓(getUserVestingIds 返回 id 数组),每笔需读取 getVestingInfo 与 getVestingStatus 两个方法,N 笔即 2N 次调用,每次一个 RPC 往返,页面数字逐项呈现。领取若按 id 逐个 release,用户需连续签名 N 次,中途任一拒绝都会留下部分领取状态。

解决方案:读写两侧均向合约的批量方法收敛。读侧以 readContracts 将 N × 2 摊平为一次 multicall,返回后按 index * 2 / index * 2 + 1 还原;写侧调用合约自带的 releaseMultiple(ids),一次完成。两侧遵循同一约束:摊平与还原仅存在于同一函数内,中间不插入其他调用,且不允许外部重排下标。

// 读:N 个锁仓 × 2 个方法,摊平为一次 multicall
const data = ids.flatMap((id) => [
  { ...props, functionName: 'getVestingInfo', args: [id] },
  { ...props, functionName: 'getVestingStatus', args: [id] },
])
const result = await readContracts(wagmiConfig, { contracts: data })

// 还原:结果与入参一一对应
return ids.map((id, index) => ({
  vestingId: id,
  ...result[index * 2].result,
  releasable: result[index * 2 + 1].result[0],
  released: result[index * 2 + 1].result[1],
  remaining: result[index * 2 + 1].result[2],
  elapsedDays: result[index * 2 + 1].result[3],
}))

// 写:同一批 id 一次领取完毕,而非逐个 release
await ReleaseMultiple(ids)

结果:10 笔锁仓由 20 次读 + 10 次签名降为 1 次读 + 1 次签名。全站 21 处批量读取均为该结构 —— 债券奖励查询的 40 次 eth_call 亦以同样方式合并为 1 次。

#难点三:一次交易三段等待,一个转圈说不清卡在哪

问题:用户点下确认,实际有三段等待 —— 钱包弹窗等他签名、交易广播后等打包、打包完成等回执。三者若合并成一个转圈,界面上没有任何区别:用户不知道是自己还没签、还是签了链上没动、还是已经失败该重来。连续操作时更糟,上一笔还在等,下一笔的提示又弹出来,分不清哪条对应哪笔。

解决方案:提示按状态建模,而不是按「加载中 / 完成」两态。状态分四档 —— 等待签名、已广播待回执、成功、失败;授权与业务交易共用这套状态,但文案分开,同样是等待,授权那步说的是「请在钱包中确认授权」,业务那步说的是「请在钱包中确认交易」。连续操作时最多同时 3 条、6 秒自动关闭,不堆叠。失败在统一的 catch 里收尾:复位按钮的 loading、把该笔的弹窗置为失败态、清空挂起中的交易标识,避免按钮停在那转。

结果:用户能分辨「还没签」与「签了没回执」;一笔失败不会把下一笔的按钮带进 loading;连续下单时提示有数量与时长上限,不会糊成一片。这里有一处刻意的收敛 —— 前端不解析合约回滚原因:错误串的格式因钱包与链而异,自行解析会得到时对时错的文案,页面只呈现失败,具体原因交区块浏览器。省下的是解析逻辑,代价是用户要多跳一次。

#项目成果

  • 业务收益:赎回在每日限额约束下仍具备调整空间 —— 队列内一次互换替代「取消申请 + 重新排队」,兑付时间不再由申请时序单向决定
  • 操作成本:多笔锁仓由 N 次签名收敛为 1 次(ReleaseMultiple);债券奖励查询由 40 次 eth_call 收敛为 1 次;10 笔锁仓的读取由 20 次收敛为 1 次
  • 加载性能:10 条路由中 8 条按需加载,图表库不进入首屏;vendor 按用途分为 9 块,业务发布不使第三方库缓存失效;产物以内容哈希命名并在 10 KB 阈值上预压缩
  • 交易可靠性:六个合约模块 21 处写操作在发交易前先比对链 ID,链不匹配先切换,拒绝即中止;交易反馈拆为四态(等待签名 / 待回执 / 成功 / 失败),授权与业务交易文案各异
  • 交付规模:10 条路由、5 种语言、约 2.2 万行,四个环境(dev / test / prev / prod)各持独立配置
  • 工程质量:strict: true(app 与 node 两份 tsconfig);16 项 VITE_* 覆盖全部合约地址,源码零地址字面量

#项目链接

源码托管于公司私有 Git 仓库,受知识产权约束,不对外公开。替代说明材料为节点销售、债券、质押与解锁等页面的部分截图,以及本页的技术方案与难点复盘。

项目截图 / SCREENSHOTS

home
01 home
BVT 解锁
02 BVT 解锁
质押 BVT 得 bBGT,质押 bBGT 得 sWBERA
03 质押 BVT 得 bBGT,质押 bBGT 得 sWBERA
债券
04 债券
跨链桥,BNB、Berachain 的 USDT swap
05 跨链桥,BNB、Berachain 的 USDT swap
账户,用户获得的收益统计以及下属团队的收益
06 账户,用户获得的收益统计以及下属团队的收益
十条路由按六个业务面排布,每页承担一段独立的业务职责
07 十条路由按六个业务面排布,每页承担一段独立的业务职责
多笔锁仓的读取合并为一次 multicall,领取亦收敛为一笔交易
08 多笔锁仓的读取合并为一次 multicall,领取亦收敛为一笔交易
赎回队列:同一目标日内两两互换,单次最多选择两条
09 赎回队列:同一目标日内两两互换,单次最多选择两条
写操作前先读 chainId,与目标链不一致则先执行 switchChain
10 写操作前先读 chainId,与目标链不一致则先执行 switchChain

返回作品集