#项目背景
BVT 业务线有三个前端入口,服务于同一批合约与同一批用户:运营使用的兑换后台 4 页、用户自助领取额度的页面 2 页、运营使用的债券看板 7 页,合计 13 页。
三个入口共用同一套脚手架与目录约定,因此没有拆成三个工程,而是置于同一工程内,各自保留入口与构建配置;一次构建产出三份静态产物,按各自的 base 部署到同一域名的三个子路径。
#项目角色
独立承担三个应用的前端开发:工程组织与构建产物拆分、路由与访问控制、债券看板请求层、各页面的表格与日历视图,以及三个子路径的部署配置。
合约部署与后端接口由对应团队提供。
#技术方案
#整体架构
一个工程内并列三个应用,共用同名目录与同一套脚手架,各自持有 lockfile 与构建配置;未抽取共享包 —— 共享包会将三个独立发布的入口重新绑定至同一发版周期。数据在应用边界处分叉:兑换后台经 src/contract/ 读写 TokenSwap 合约,领取页仅读取合约,债券看板经 src/services/ 下 9 个服务模块访问后端 22 个接口(19 个读、3 个写)。
#方案对比
| 选择 | 替代方案 | 理由 |
|---|---|---|
| 单工程多应用 | 三个独立仓库 | 三个入口共用同一套脚手架与约定,规则修改一次即可在三处生效 |
| 应用间不抽共享包 | 抽取 shared 包 | 共享包会把三个独立发布的入口绑回同一发版周期,同构性靠目录同名维持 |
| React 19 + Vite | Next.js | 三个入口均为登录后或纯前端场景,无 SSR 与 SEO 需求,静态产物成本最低 |
| TanStack Query | 自行管理 loading / error | 日历切月与明细翻页是「同一数据源、不同参数」,缓存与失效交给该库 |
| wagmi / viem | 自行接 provider 并拼 ABI | 链接口只需读合约、写合约、等回执,钱包连接能力已具备 |
三份产物均为纯静态资源,base 在构建期确定(兑换后台读 VITE_BASE_URL、领取页固定 /airdrop/、债券看板固定 /bvt-bond-admin/),缓存失效依赖文件名变化。
#核心业务
兑换后台(4 页):向兑换合约存入 BVT,授权不足时先 approve 再 deposit;粘贴地址或导入 CSV / Excel 批量设置额度与白名单;输入地址查询剩余额度。
额度领取(2 页):用户连接钱包后直接读合约,呈现质押、提现、节点三部分可领额度与可迁移的旧币余额,自行发起领取或迁移;支持中英文与移动端布局,无登录态。
债券看板(7 页):当日销售额与支付方式占比;销售日历按月查看每日销售额,进入某日查看申购、推荐与利息明细;到期日历查看未来某日兑付金额;用户持仓按地址与等级筛选并导出 CSV;国库查看利息与分级释放流水;权限页维护管理员账号与启停状态。
#难点与解决方案
#难点一:三个入口同域不同路径,构建配置与产物须严格对齐
问题:三个应用最终部署于同一台静态服务器的三个子路径下,路径前缀各不相同。base 配置错误将导致整站白屏或资源 404,而该错误在本地 base: '/' 的开发态中无法暴露。三份产物的文件名策略亦不一致,缓存失效依赖文件名变化,产物混放将导致相互覆盖。
解决方案:base 统一于构建期确定,不读取运行时值 —— 兑换后台保留 VITE_BASE_URL 环境变量(默认 /),领取页与债券看板直接固定为 /airdrop/、/bvt-bond-admin/;产物文件名携带构建标识,兑换后台使用内容哈希、领取页每次构建更换 buildId、债券看板使用时间戳 buildHash 并注入 <meta name="build-time">;部署按应用目录平铺,互不越界。
结果:一次构建产出三份产物,各自输出至目标路径,单一应用变更不影响另外两个应用的缓存;线上核对版本无需查阅构建日志,读取页面中的 build-time 即可确认运行版本。
#难点二:三个入口三种访问控制,通用守卫无法同时适配
问题:兑换后台仅有一个判定依据 —— 合约 owner,判定后即完成;债券看板包含账号表、模块权限与账号启停状态,同一个 403 在两侧含义不同;领取页无登录态。强行套用通用守卫,要么为无需登录的页面附加登录环节,要么抹平模块级限制。
解决方案:守卫仅挂载于需要的层级。两个后台均将守卫置于布局层,子路由继承;判定逻辑各自保留于对应 store。领取页不挂载守卫,直接通过 useAccount() 获取地址并读取合约。
// 兑换后台:链上 owner 比对
const owner = await getOwner()
setAdmin(owner.toLowerCase() === address.toLowerCase())
// 债券看板:登录态 + 后端账号表
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
结果:两个后台新增页面天然具备守卫,无需单独补充判断;领取页亦不会被附加其不需要的登录页。代价是守卫实现不存在统一形态,跨应用修改代码需先确认对应判定逻辑。
#难点三:月历的格子与数据不是一一对应的
问题:三个视图都是日历。格子要按整月补齐 —— 首日之前与月末之后都是空位;某一天没有数据时也不能跳过,否则后面的日期整体左移一格,整月错位,而这类错位在图上看不出异常,只会让读数对错了日期。数据侧另有两处麻烦:逐格请求时,31 天的月份要发 31 次调用;把整月连同明细一并拉回,单次响应又大到无法分页。
解决方案:月历只取当月日汇总,一次请求拿回,按日期建立索引,渲染时由格子去查;格子表按「首日星期几 + 当月天数」生成,无数据的日期保留占位。明细另起一层,选中某日后按 page / page_size 单独请求,分页交给服务端。切换月份时旧请求按 query key 作废,避免两个月的数字落进同一张表格。
结果:单月固定 1 次请求,只有翻页才产生新的调用;某日无数据不再造成整月错位;切月时不会出现新旧月份数据混排。
#项目成果
- 交付:一个工程、三个应用、13 个页面上线,一次构建产出三份静态产物
- 部署:按
base部署至同一域名的三个子路径,产物互不覆盖,缓存独立 - 权限:两个后台守卫挂载于布局层、子路由继承;领取页按钱包地址直接读取,不设登录
- 数据:债券看板 9 个服务模块、22 个接口统一信封与错误码;日历单月 1 次请求,当日明细分页于服务端完成
- 健壮性:信封在拦截器统一解包,四个业务码各有处置文案;轮询请求静默失败,不堆叠提示;401 自动清登录态并跳转;批量写入的预览只渲染前 50 行,整批一次提交
- 可复制:修改合约地址、接口地址与
base后重新构建,即可得到一套独立实例
#项目链接
源码托管于公司私有 Git 仓库,受知识产权约束,不对外公开。替代说明材料为三个应用的部分页面截图,以及本页的技术方案与难点复盘。