Bytebase 前端 React 迁移实战:项目数据库详情 Catalog 面板的 Vue Bridge 移除与 React 原生实现解析
本指南以 Bytebase 仓库内的 React 迁移实现计划(docs/superpowers/plans/2026-04-13-project-database-detail-catalog-react-parity.md)为主体,深入解析"项目数据库详情页 → Catalog(敏感数据目录)标签页"从 Vue 桥接(bridge)迁移到 React 原生实现的全过程。读者将掌握该面板的组件架构、Catalog 数据扁平化逻辑、变更与授权写入语义、权限与订阅功能门控,以及对应的测试与验证命令,并可直接对照仓库中已落地的源码继续深挖。
一、迁移背景:为什么要移除 Vue Bridge
Bytebase 前端正在经历从 Vue 向 React 的渐进式迁移。在迁移中间态,部分页面通过 createApp 临时挂载 Vue 组件(如 NConfigProvider、OverlayStackManager.vue、DatabaseSensitiveDataPanel.vue)实现"React 壳 + Vue 芯"的桥接。这种方式虽然能让旧功能继续运行,但带来两个问题:
- 双框架渲染成本:每个页面多出一套 Vue 应用实例的创建、配置与叠加层管理,包体、性能与维护复杂度都随之上升;
- Parity 回归风险:桥接层里的 UI 细节(表格、抽屉、下拉、空态)与 React 侧组件体系(shadcn 风格 +
@base-ui/react+ Tailwind CSS v4)不一致,容易出现交互差异。
本计划的目标非常明确:从项目数据库详情页的 Catalog 标签页中移除 Vue 桥接,用仓库自有的 shadcn 风格 React 基础组件重建一个 React 原生实现,恢复完整的 Catalog 行为一致性(parity)。该计划对应文件 2026-04-13-project-database-detail-catalog-react-parity.md 中以 - [ ] 复选框组织的 4 个任务、共 17 个步骤,本文逐一展开并结合仓库现状给出实现事实。
需要说明:计划文档中预期的目标路径为
frontend/src/react/pages/project/database-detail/...,而当前仓库中该功能已实际落地于frontend/src/routes/project/database-detail/...目录下(与仓库整体 React 路由目录结构一致),以下源码引用均以仓库实际路径为准。
二、总体架构:面板壳 + 纯展示表格 + 聚焦式授权对话框
计划文档给出了明确的架构分层,这也是理解整个迁移的关键:
Reintroduce a React-owned
DatabaseCatalogPanelthat orchestrates catalog flattening, permissions, feature gating, row selection, inline edits, delete cleanup, and grant-access dialog state. Keep the UI split into a page-specific panel shell, a presentationalSensitiveColumnTable, and a focusedGrantAccessDialog, all backed by existing Vue/Pinia stores throughuseVueState.
对应到当前仓库,三个组件分别落地为:
| 职责层 | 文件 | 职责 |
|---|---|---|
| 面板壳(orchestrator) | DatabaseCatalogPanel.tsx | 数据拉取、扁平化、权限与功能门控、行选择、内联编辑、删除清理、对话框状态 |
| 纯展示表格 | SensitiveColumnTable.tsx | 行渲染、复选框选择、内联下拉编辑、删除操作、空态,无任何 store 直连 |
| 授权对话框 | GrantAccessDialog.tsx | 资源预览、原因/过期时间/成员采集、CEL 表达式生成与策略追加 |
三者之外的配套组件还包括 MarkSensitiveDataSheet.tsx(批量标记敏感数据)。
当前仓库实现中,面板不再依赖 createApp / NConfigProvider / OverlayStackManager / DatabaseSensitiveDataPanel.vue / Naive UI,而是通过 useDatabaseCatalog、useSemanticTypes hooks 与 useAppStore(Zustand store,后端为 Pinia 数据层的 React 入口)读取数据。例如 useDatabaseCatalog.ts 注释明确写道它是 "React port of the legacy Pinia useDatabaseCatalog composable":先按 bb.databaseCatalogs.get 权限门控地拉取 catalog,再响应式返回缓存条目,未加载时回退到稳定的空 catalog(emptyDatabaseCatalog),避免 selector 死循环。
三、Task 1:恢复 React Catalog 面板壳
涉及文件:
- 修改:DatabaseCatalogPanel.tsx
- 参考(旧 Vue 实现):
frontend/src/components/Database/DatabaseSensitiveDataPanel.vue - 参考(共享类型与工具):
frontend/src/components/SensitiveData/types.ts、frontend/src/components/SensitiveData/utils.ts
3.1 面板需要拥有的状态与行为
计划要求 DatabaseCatalogPanel 全面接管以下职责,当前仓库实现逐条对应:
- catalog 读取:
useDatabaseCatalog(database.name, false)(第二个参数skipCache = false,即优先走缓存); - store 响应式读取:通过
useAppStore选择器订阅 catalog、语义类型、数据分类配置、实例订阅功能; - 扁平化:把关系型列、对象型(NoSQL)schema 字段、表级分类统一拍平成
MaskData[]; - 本地 UI 状态:搜索文本、勾选行、功能(feature)对话框、授权对话框、待删除项等
useState; - 权限检查:
bb.databaseCatalogs.update(更新 catalog)、以及授权所需的三个权限; - 设置拉取:语义类型(
SEMANTIC_TYPES)与数据分类(DATA_CLASSIFICATION)两类 workspace 设置; - 变更处理:语义类型编辑、分类编辑、删除清理、授权流程打开。
当前实现中,设置拉取通过 useEffect 调用 getOrFetchSettingByName(Setting_SettingName.SEMANTIC_TYPES, true) 与 getOrFetchSettingByName(Setting_SettingName.DATA_CLASSIFICATION, true) 完成;数据库切换(database.name 变化)时通过另一个 useEffect 重置所有本地状态(搜索词、勾选行、各对话框、待删除项)。
3.2 保持既有变更语义(mutation semantics)
计划明确要求复用 Vue 面板的写入路径,当前实现逐一对应:
| 操作 | 行为 |
|---|---|
| 语义类型变更 | 就地修改行目标(item.target.semanticType = semanticTypeId)后调用 updateDatabaseCatalog(catalog) 持久化,并弹出 SUCCESS 通知 |
| 分类变更 | 就地修改 item.target.classification 后同样 updateDatabaseCatalog 持久化 |
| 删除 | 先从本地勾选集移除该行 → 清空目标上的 semanticType/classification(按目标类型判断)→ 持久化 catalog → 再调用 removeMaskingExceptions 清理项目 masking 豁免策略中匹配该列的豁免 |
| 授权 | 仅在存在勾选行且权限/功能门控放行时打开 React 对话框 |
删除路径是其中最有代表性的"清理级联":handleDelete 先清本地勾选,再用 isCurrentColumnException(定义于 utils.ts)逐条过滤项目 MASKING_EXEMPTION 策略中的豁免表达式,匹配的豁免会被剔除后通过 upsertPolicy 写回。也就是说,删除敏感标记不只是清 catalog,还会顺带收回此前授予的访问豁免,避免残留授权形成数据泄漏面。
3.3 NoSQL 分支与功能门控(parity 的关键)
计划要求原样继承 Vue 面板的 NoSQL 分支行为:
- 对象 schema 扁平化仍然要呈现嵌套字段:
flattenObjectSchema递归遍历ObjectSchema(OBJECT类型沿structKind.properties逐层下钻,ARRAY类型取其元素类型继续递归),最终把叶子节点拍平成schema.table.column形式的MaskData,并在扁平化结果上标记disableClassification: true(对象字段不支持分类); - 行选择禁用:当
instanceV1MaskingForNoSQL(instance)返回 true(即该实例的 NoSQL 类型不支持批量授权)时,表格的勾选列不再渲染,Mark Sensitive Data按钮也被隐藏; - 只读目标:语义类型目标不支持分类(如表级分类行
disableSemanticType: true),对象字段不支持分类(disableClassification: true),这些行在下拉编辑时保持禁用; - 订阅缺失:缺少
FEATURE_DATA_MASKING功能时,"Grant Access"与"Mark Sensitive Data"点击后打开的是 feature 对话框(FeatureAttention付费引导)而非实际操作。
四、Task 2:用 shadcn 风格基础组件重建 UI 组件
涉及文件:
- 新建:SensitiveColumnTable.tsx、GrantAccessDialog.tsx
- 参考:
frontend/src/components/SensitiveData/components/SensitiveColumnTable.vue、frontend/src/components/SensitiveData/exemptionDataUtils.ts、frontend/src/components/SensitiveData/utils.ts
4.1 SensitiveColumnTable:保持纯展示
计划要求该组件保持 presentational:不直连 store,所有变更通过回调冒泡,勾选行由 props 受控。当前实现的 props 契约完整体现了这一点:
columnList/checkedColumnList:行数据与受控勾选集合(由父组件持有);showSelection/canEdit/showOperation:分别控制勾选列、编辑态、操作列是否可用;semanticTypeOptions/classificationOptions:下拉选项由父组件注入;onCheckedColumnListChange/onSemanticTypeChange/onClassificationChange/onDelete:全部通过回调上抛。
实现细节与计划要求逐条对齐:
- 共享 Table 原语:复用
frontend/src/components/ui/table的Table/TableBody/TableCell/TableHead/TableHeader/TableRow,以及frontend/src/components/ui/checkbox、frontend/src/components/ui/select、frontend/src/components/ui/button; - 行选择:以
schema::table::column拼出的 key 为唯一标识(itemKey),支持单选/全选,全选提供indeterminate(半选)状态; - 内联编辑:
EditableSelect封装空值语义——空值用哨兵值__EMPTY__占位,选择该哨兵项即清空(写入空字符串),避免 Select 组件无法表达"无值"; - schema 限定表名与
-占位:表名单元格用${schema}.${table}(无 schema 时仅表名),列名为空时显示空占位符,行为与 Vue 面板一致; - 删除与空态:操作列提供垃圾桶按钮(带
aria-label与 title),空数据显示居中的 "no-data" 占位。
4.2 GrantAccessDialog:保留策略写入逻辑
授权对话框是"数据治理"语义的核心载体,计划要求恢复其策略写入逻辑且不再引入 Vue drawer 或桥接包装。当前实现的关键流程:
- 资源预览:把选中的
SensitiveColumn[]通过convertSensitiveColumnToDatabaseResource转换为DatabaseResource(utils.ts 中的实现将 database 全名、schema、table、columns 组装为资源对象),对话框打开时据此初始化资源列表; - 三种范围模式:
ALL(全部数据库,当前为禁用态)、EXPRESSION(CEL 表达式)、SELECT(手动选择),通过RadioGroup切换,且 SELECT ↔ EXPRESSION 之间切换时做双向转换(convertToConditionGroupExpr/convertToDatabaseResources),转换过程用modeChangeRequestIdRef做请求竞态防护; - 表达式生成:核心工具是 maskingExemption.ts 中的
buildMaskingExemption——它将 CEL 表达式经rewriteResourceDatabase(...)重写为豁免条件,并将过期时间编码为request.time < timestamp("...")子句,最后组装成MaskingExemptionPolicy_Exemption; - 追加而非替换:提交时先
getOrFetchPolicyByParentAndType取项目现有MASKING_EXEMPTION策略,取其中已存在的exemptions数组,再[...existed, exemption]追加后upsertPolicy写回——这正是计划中强调的"append to existing exemptions instead of replacing them"语义; - 表单要素:原因(description)、过期时间(
ExpirationPicker,最小时间为当天dayjs().startOf("day"),不选即永不过期)、成员(AccountMultiSelect,必填);成员为空或表达式无效时提交按钮禁用; - 付费墙:选择 EXPRESSION/SELECT 模式而订阅缺少
FEATURE_DATA_MASKING时,打开共享的FeatureModal引导升级,而不是阻塞操作。
4.3 面板接线
DatabaseCatalogPanel 将扁平化后的 filteredColumnList 与回调交给 SensitiveColumnTable;把勾选行映射为 { database, maskData }(即 SensitiveColumn 形状)传给 GrantAccessDialog;对话框关闭(closeGrantAccessDialog)时同步清空勾选行,与 Vue 行为保持一致。
五、Task 3:重建并扩展 React 测试覆盖
涉及文件:
- 新建:DatabaseCatalogPanel.test.tsx(当前仓库已落地,约 1200 行)
- 修改:ProjectDatabaseDetailPage.test.tsx
计划把测试分为两层,并特别点名"旧版本最容易漏掉"的 parity 回归点:
面板级测试(第一层,必须覆盖):
- 渲染扁平化后的关系型行;
- 勾选行后启用 Grant Access 按钮;
- 缺少 masking 功能时打开 feature 对话框;
- 传给权限守卫(guard)的权限输入;
- 删除选中行后从勾选集合中清除;
- 内联语义类型与分类更新;
- store 加载完成前 catalog 为 undefined 的渲染安全。
新增 parity 测试(第二层,旧版易漏):
- NoSQL 模式下禁用行选择,但仍渲染扁平化的对象 schema 行;
- 删除路径从策略 store 中移除匹配的 masking 豁免;
- Grant Access 提交是追加而非替换已有豁免;
- 缺少
bb.databaseCatalogs.update权限时操作控件被禁用或隐藏。
页面级集成测试(第三层,保持最小化): ProjectDatabaseDetailPage.test.tsx 只聚焦标签路由、catalog 标签页的权限守卫行为、以及选中标签后的面板渲染,不把详细工作流断言塞进页面级文件。
当前仓库的 DatabaseCatalogPanel.test.tsx 中可以看到对应的测试基建:通过 makeColumn / makeObjectSchema / makeDatabaseCatalog 构造 fixture——其中 makeObjectSchema 构造了 contact.email / contact.phone 两层嵌套的 OBJECT 结构来验证递归扁平化,makeDatabase 使用 instances/inst1/databases/db1 这样的资源名,并通过 createRoot + act 在 React 测试环境下驱动交互。
六、Task 4:验证、清除桥接假设并收尾
6.1 确认无页面级 Vue 桥接依赖
最终实现必须确认不再导入或依赖:createApp、NConfigProvider、themeOverrides、OverlayStackManager.vue、DatabaseSensitiveDataPanel.vue、NaiveUI。Catalog 标签页从此"fully React-owned",即便其数据仍来自 Vue 背书的 stores——注意当前仓库中 frontend/src/components/header/no-vue-deps.test.ts 这类测试的存在,说明"React 组件不得携带 Vue 依赖"已经是一个被测试强约束的工程红线。
6.2 完整验证命令
计划给出的命令可以直接在仓库根目录执行(pnpm --dir frontend 指定工作目录为前端包):
# 自动修复(lint 等)
pnpm --dir frontend fix
# 全量检查
pnpm --dir frontend check
# 类型检查
pnpm --dir frontend type-check
# 定向运行 catalog 相关测试
pnpm --dir frontend test -- --run frontend/src/routes/project/database-detail/panels/DatabaseCatalogPanel.test.tsx frontend/src/routes/project/ProjectDatabaseDetailPage.test.tsx
(计划文档中写的是旧路径 frontend/src/react/pages/...,运行前请以仓库当前 frontend/src/routes/... 实际路径为准。)
6.3 Diff 自查
最后通过 git diff 检查改动范围是否严格限定在 catalog 相关五个文件内,确保没有意外的越界改动:
git diff -- frontend/src/routes/project/database-detail/panels/DatabaseCatalogPanel.tsx frontend/src/routes/project/database-detail/catalog/SensitiveColumnTable.tsx frontend/src/routes/project/database-detail/catalog/GrantAccessDialog.tsx frontend/src/routes/project/database-detail/panels/DatabaseCatalogPanel.test.tsx frontend/src/routes/project/ProjectDatabaseDetailPage.test.tsx
七、总结:一次可复用的"React 化"迁移样板
从本计划可以提炼出一套可复用的 React 迁移模式,适用于 Bytebase 中其他仍处于 Vue 桥接状态的面板:
- 壳层归 React:面板组件接管数据拉取、扁平化、权限、门控与全部对话框状态,移除
createApp级桥接; - 展示层归组件:表格与对话框保持 presentational,数据与回调全部由 props 注入,为测试和复用提供干净边界;
- 数据层复用:通过 hooks(
useDatabaseCatalog、useSemanticTypes)与 store 选择器(useAppStore)继续消费既有数据能力,迁移 UI 而不是重写业务; - parity 靠测试锁定:专门为 NoSQL 行选择禁用、删除级联清理豁免、授权追加语义、权限缺失下的控件状态等"最易回归"点补充显式断言;
- 验证闭环:
fix → check → type-check → 定向测试四步走,配合git diff范围自查收尾。
对想要深入研读实现细节的读者,推荐按以下顺序阅读源码:面板壳 DatabaseCatalogPanel.tsx → 数据模型 types.ts → 扁平化与豁免匹配工具 utils.ts → 表达式构建 maskingExemption.ts → 授权对话框 GrantAccessDialog.tsx,最后用测试文件 DatabaseCatalogPanel.test.tsx 验证自己对各行为的理解是否与仓库锁定的一致。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



