Reduce denied-action noise in knowledge permissions

Knowledge pages were still rendering disabled action controls for users without edit/delete/manage grants, which made readonly states look broken and invited backend denials. Hide unavailable operation columns and buttons, keep busy copy/unpublished states visible, and retain the permission investigation artifacts that explain the surrounding migration context.

Constraint: Existing knowledge permission APIs already compute action grants; this change only adjusts presentation around those grants.
Rejected: Keep disabled controls with permission tooltips | readonly users still see actions they cannot perform, which obscures the actual relation-model behavior.
Confidence: medium
Scope-risk: moderate
Directive: Do not reintroduce disabled mutation controls for users lacking the matching action permission without validating the readonly UX.
Tested: src/backend/.venv/bin/pytest src/backend/test/test_permission_relation_bindings.py -q
Tested: npm run build in src/frontend/platform
Not-tested: Browser manual QA for every knowledge library/file/QA permission combination
This commit is contained in:
Y1fe1Zh0u
2026-04-28 11:27:20 +08:00
parent 5551a2c046
commit 60057fc4ef
8 changed files with 932 additions and 142 deletions
@@ -0,0 +1,612 @@
# 个人提交代码质量与重构评估报告
日期:2026-04-27
统计范围:`upstream/2.5.0-PM..HEAD`
作者口径:仅统计 `Y1fe1Zh0u <zhouyifei210@gmail.com>` 的非 merge commit
评估目标:判断当前个人提交是否存在“史山化”风险,并给出量化依据、实现层判断和可执行重构建议。
## 1. 结论摘要
当前代码不是典型“史山”,更准确的判断是:
> 这是一个高复杂度、多版本兼容、权限迁移驱动的系统改造。代码量大有客观原因,当前实现有测试保护和一定抽象边界,但 API 层和部分核心 service 已经变厚,需要做边界治理。
综合评分:`72 / 100`
等级判断:`可维护,但需要治理复杂度分布`
不是“立即需要推倒重写”的状态,也不建议大规模重写。更合理的方向是小步抽取:保持 API contract 不变,把 endpoint 中的领域规则、兼容逻辑、展示组装逻辑下沉到 domain/application service。
## 2. 代码量统计
### 2.1 Commit 统计
| 指标 | 数值 |
|---|---:|
| 当前分支非 merge commits 总数 | 543 |
| 个人非 merge commits | 205 |
| 个人 commit 占比 | 37.8% |
| 触达唯一文件数 | 372 |
| 生产文件数 | 283 |
| 测试文件数 | 89 |
### 2.2 行数统计
| 指标 | 数值 |
|---|---:|
| 累计新增 | 47,531 行 |
| 累计删除 | 6,499 行 |
| 累计 churn | 54,030 行 |
| 平均每 commit churn | 263.6 行 |
| 平均每 commit 触达文件数 | 5.18 |
说明:这里的 churn 是 commit 历史累计工作量,不是最终 diff。它更适合衡量“个人实际改动压力”和“代码热点反复修改程度”。
### 2.3 按模块分类
| 分类 | 文件数 | 新增 | 删除 | Churn | Churn 占比 |
|---|---:|---:|---:|---:|---:|
| 测试 | 89 | 18,336 | 807 | 19,143 | 35.4% |
| 后端生产代码 | 118 | 11,633 | 2,315 | 13,948 | 25.8% |
| Client 前端 | 61 | 6,858 | 1,949 | 8,807 | 16.3% |
| Platform 前端 | 82 | 4,080 | 1,347 | 5,427 | 10.0% |
| 文档/规格 | 16 | 4,954 | 4 | 4,958 | 9.2% |
| 其他 | 5 | 1,566 | 15 | 1,581 | 2.9% |
| 运维/工具 | 1 | 104 | 62 | 166 | 0.3% |
正向信号:
- 测试 churn 占比达到 `35.4%`,说明不是单纯堆业务代码,有较强回归保护意识。
- 平均每 commit 触达 `5.18` 个文件,虽然不小,但不是单 commit 巨型倾倒。
- 个人提交中大量 commit message 是行为意图型,例如 `Prevent...``Align...``Restore...`,可追溯性较好。
风险信号:
- 删除/新增比为 `6,499 / 47,531 = 13.7%`,说明主要是增量兼容和修复,清理不足。
- 权限、知识空间、权限 UI 的热点集中度较高。
- 存在重复 subject 的 commit,说明有 cherry-pick、回放、修复反复痕迹。
## 3. 时间分布
| 日期 | 个人 commits |
|---|---:|
| 2026-04-20 | 5 |
| 2026-04-21 | 12 |
| 2026-04-22 | 14 |
| 2026-04-23 | 61 |
| 2026-04-24 | 26 |
| 2026-04-25 | 33 |
| 2026-04-26 | 37 |
| 2026-04-27 | 17 |
判断:
- `2026-04-23` 单日 61 个 commit,是明显的高压迁移/修复峰值。
- 这种节奏下,代码出现兼容分支、兜底逻辑、重复修复痕迹是合理现象。
- 后续应进入“收口和治理”阶段,而不是继续以同样方式堆补丁。
## 4. 热点文件统计
| Churn | 新增 | 删除 | 文件 |
|---:|---:|---:|---|
| 1,840 | 1,657 | 183 | `src/backend/test/test_knowledge_space_service.py` |
| 1,546 | 1,546 | 0 | `docs/architecture/数据库表结构与关联说明.md` |
| 1,454 | 1,424 | 30 | `src/backend/test/test_permission_api_integration.py` |
| 1,320 | 724 | 596 | `src/frontend/client/src/pages/Subscription/ChannelShareDialog.tsx` |
| 1,314 | 1,053 | 261 | `src/backend/bisheng/permission/api/endpoints/resource_permission.py` |
| 1,163 | 964 | 199 | `src/backend/bisheng/permission/domain/services/permission_service.py` |
| 1,076 | 942 | 134 | `src/backend/test/test_knowledge_service_rebac_bridge.py` |
| 895 | 604 | 291 | `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py` |
| 810 | 745 | 65 | `src/backend/bisheng/approval/domain/services/approval_service.py` |
| 699 | 488 | 211 | `src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx` |
判断:
- 热点不是随机分布,而是集中在权限、知识空间、审批、权限 UI。
- 这些是 v2.5 权限迁移的核心业务区域,热点集中有业务必然性。
- 真正需要治理的是 `resource_permission.py``permission_service.py``knowledge_space_service.py`、前端权限组件双写。
## 5. 实现层质量评估
### 5.1 权限核心:不是乱写,但 facade 偏厚
文件:`src/backend/bisheng/permission/domain/services/permission_service.py`
| 指标 | 数值 |
|---|---:|
| 文件行数 | 1,706 |
| 函数数 | 43 |
| 超过 80 行函数 | 3 |
| 超过 150 行函数 | 0 |
| 最大函数 | 101 行 |
最大函数和关键函数:
| 函数 | 行数 | 分支数 | 判断 |
|---|---:|---:|---|
| `batch_write_tuples` | 101 | 19 | OpenFGA 写入保护逻辑较重,但可解释 |
| `check` | 91 | 15 | 核心权限检查路径,复杂但未失控 |
| `list_accessible_ids` | 82 | 15 | 列表可见性逻辑偏复杂 |
判断:
- 该文件不是“巨型函数式史山”。
- 问题是类级职责较宽,同时承担 OpenFGA check、租户短路、owner fallback、legacy alias、implicit dept admin、tuple fallback、subject enrichment 等多种职责。
- 适合保留 `PermissionService` 作为 facade,但把内部协作者逐步抽出。
### 5.2 细粒度权限:方向正确,但规则引擎需要阶段化
文件:`src/backend/bisheng/permission/domain/services/fine_grained_permission_service.py`
| 指标 | 数值 |
|---|---:|
| 文件行数 | 514 |
| 函数数 | 17 |
| 超过 80 行函数 | 1 |
| 最大函数 | 120 行 |
核心函数:
| 函数 | 行数 | 分支数 |
|---|---:|---:|
| `get_effective_permission_ids_async` | 120 | 29 |
判断:
- 这是当前权限系统真正的“规则引擎”。
- 它回答的是“用户对资源最终有哪些 action permission ids”。
- 复杂度是业务必需的,不应简单按行数否定。
- 但应该拆成阶段:lineage 构造、tuple 读取、binding 匹配、legacy 过滤、implicit/default 权限补偿。
### 5.3 API 层:当前最需要治理
文件:`src/backend/bisheng/permission/api/endpoints/resource_permission.py`
| 指标 | 数值 |
|---|---:|
| 文件行数 | 1,356 |
| 函数数 | 54 |
| 超过 80 行函数 | 2 |
| 超过 150 行函数 | 1 |
| 最大函数 | 180 行 |
核心函数:
| 函数 | 行数 | 分支数 |
|---|---:|---:|
| `authorize_resource` | 180 | 43 |
| `_add_implicit_permission_entries` | 88 | 16 |
| `_apply_binding_metadata_to_permissions` | 71 | 12 |
| `get_resource_permissions` | 67 | 6 |
这里所谓“API 承载功能过多”,不是说 API 不该提供这些功能。
API 当然应该继续提供:
- 授权
- 撤销
- 查询权限列表
- 查询可授权 relation models
- 管理 relation model
- 为前端返回完整展示数据
- 兼容老数据展示
问题是这些能力的实现细节不应该主要长在 endpoint 文件里。
当前 API 文件承载的职责包括:
| 当前职责 | 更合适的位置 |
|---|---|
| relation/model 映射 | `RelationModelService` |
| grant tier 校验 | `GrantPolicyService` |
| 调用人是否能授权 | `GrantPolicyService` |
| binding key / binding metadata | `PermissionBindingService` |
| legacy knowledge_space 兼容 | `PermissionCompatibilityService` |
| creator owner entry | `PermissionEntryAssembler` |
| implicit permission entries | `PermissionEntryAssembler` |
| HTTP 参数和 response | endpoint |
建议目标:
| 指标 | 当前 | 目标 |
|---|---:|---:|
| `authorize_resource` | 180 行 | 40-70 行 |
| endpoint 文件 | 1,356 行 | 500-800 行 |
| API contract | 不变 | 不变 |
| 业务规则位置 | endpoint | domain/application service |
### 5.4 知识空间:服务偏大,但不是乱
文件:`src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`
| 指标 | 数值 |
|---|---:|
| 文件行数 | 2,425 |
| 函数数 | 88 |
| 超过 80 行函数 | 3 |
| 超过 150 行函数 | 1 |
核心函数:
| 函数 | 行数 | 分支数 |
|---|---:|---:|
| `batch_download` | 157 | 34 |
| `add_file` | 139 | 24 |
| `_format_accessible_spaces` | 97 | 18 |
| `create_knowledge_space` | 77 | 6 |
| `search_space_children` | 70 | 12 |
判断:
- 不是“一个函数塞全部逻辑”的典型史山。
- 风险是 service 职责太宽:空间列表、文件上传、下载、成员、订阅、审批、权限展示都在一个类里。
- 推荐保留 facade,逐步拆内部服务。
建议拆分方向:
```text
KnowledgeSpaceQueryService
KnowledgeSpaceFileService
KnowledgeSpaceMemberService
KnowledgeSpaceSubscriptionService
KnowledgeSpaceDownloadService
```
### 5.5 审批:当前不是主要风险
文件:`src/backend/bisheng/approval/domain/services/approval_service.py`
| 指标 | 数值 |
|---|---:|
| 文件行数 | 680 |
| 函数数 | 24 |
| 超过 80 行函数 | 1 |
| 最大函数 | 86 行 |
判断:
- 审批服务复杂度可控。
- 当前不属于优先重构对象。
- 主要注意审批与权限的边界:审批服务负责状态和 reviewer,权限服务负责最终 action 判断。
### 5.6 应用/工具权限服务:相对健康
文件:`src/backend/bisheng/permission/domain/services/application_permission_service.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 14 |
| 超过 80 行函数 | 0 |
| 最大函数 | 49 行 |
文件:`src/backend/bisheng/permission/domain/services/tool_permission_service.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 12 |
| 超过 80 行函数 | 0 |
| 最大函数 | 48 行 |
判断:
- 这两个模块不是主要风险。
- 说明按资源类型拆 permission service 是有效方向。
- 后续知识库、应用、工具可以继续保持这种局部权限门面模式。
### 5.7 旧 RBAC 同步和 reconcile:保留优先,重构优先级低
文件:`src/backend/bisheng/permission/domain/services/legacy_rbac_sync_service.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 24 |
| 超过 80 行函数 | 0 |
| 最大函数 | 35 行 |
文件:`src/backend/bisheng/permission/migration/reconcile_role_access_fga.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 7 |
| 超过 80 行函数 | 0 |
| 最大函数 | 75 行 |
判断:
- 这部分是迁移保护代码。
- 函数规模可控,不建议优先大改。
- 更重要的是补充兼容说明:支持哪个版本、处理哪种历史数据、什么时候可以删除。
### 5.8 角色服务:局部偏重
文件:`src/backend/bisheng/role/domain/services/role_service.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 30 |
| 超过 80 行函数 | 1 |
| 最大函数 | 132 行 |
核心函数:
| 函数 | 行数 | 分支数 |
|---|---:|---:|
| `list_roles` | 132 | 18 |
| `update_role_with_menu` | 58 | 15 |
| `update_role` | 50 | 13 |
判断:
- 不是最高危,但角色列表查询已经混合创建者、部门 scope、权限等级、菜单、配额等多种逻辑。
- 如果后续继续改角色列表,建议顺手拆 `RoleQueryService``RoleScopeResolver``RoleCreatorResolver`
### 5.9 部门知识空间:健康
文件:`src/backend/bisheng/knowledge/domain/services/department_knowledge_space_service.py`
| 指标 | 数值 |
|---|---:|
| 函数数 | 14 |
| 超过 80 行函数 | 0 |
| 最大函数 | 54 行 |
判断:
- 职责明确,函数规模健康。
- 可以作为后续拆分知识空间大 service 的参考模式。
### 5.10 前端权限 UI:主要问题是双端重复
Client 文件:
| 文件 | 行数 | 状态 |
|---|---:|---|
| `PermissionListTab.tsx` | 597 | 偏大 |
| `PermissionGrantTab.tsx` | 353 | 可接受 |
| `SubjectSearchDepartment.tsx` | 289 | 可接受 |
Platform 文件:
| 文件 | 行数 | 状态 |
|---|---:|---|
| `PermissionListTab.tsx` | 563 | 偏大 |
| `PermissionGrantTab.tsx` | 353 | 可接受 |
| `SubjectSearchDepartment.tsx` | 343 | 可接受 |
判断:
- 单个文件不是失控级别。
- 真正风险是 Client / Platform 双份相似权限逻辑。
- 后续权限规则变动时,容易只改一端,导致行为漂移。
建议:
```text
usePermissionListModel
usePermissionGrantModel
useSubjectDepartmentSearch
```
UI 可以继续分开,业务规则、筛选、禁用、owner 保护、grantable model 处理不要双写。
## 6. “历史保护”到底是什么
当前系统中合理存在的历史保护包括:
| 历史保护 | 必要性 |
|---|---|
| 2.3/2.4 旧 RBAC 数据兼容 | 老客户不能一次性全量迁移 |
| `knowledge_space``knowledge_library` 的兼容映射 | 旧权限对象和新权限对象不一致 |
| legacy binding key | 旧 binding 缺少新 scope / include_children 信息 |
| legacy subscription viewer tuple 过滤 | 防止旧订阅状态被误认为新授权 |
| creator owner entry | 老资源缺 owner tuple 时仍能展示和管理 |
| owner fallback | OpenFGA 写入延迟或迁移缺口时保护创建者 |
| RBAC/ReBAC 同步 | 迁移期保证旧角色授权和新 FGA tuple 不漂移 |
| reconcile 脚本 | 对账和修复历史数据 |
这些逻辑不能简单删除。
建议不是删除历史保护,而是把它们集中命名:
```text
permission/domain/compat/
permission_compatibility_service.py
legacy_binding_adapter.py
legacy_knowledge_resource_adapter.py
```
每个兼容逻辑都要注明:
| 字段 | 说明 |
|---|---|
| 支持版本 | 例如 `2.3/2.4 migrated customers` |
| 历史数据形态 | 例如 `legacy role_access resource grants` |
| 保护行为 | 例如 `map knowledge_space binding to knowledge_library` |
| 删除条件 | 例如 `all active tenants migration_version >= 2.5.x` |
| 对应测试 | 例如 `test_legacy_knowledge_library_binding` |
## 7. 为什么重构不一定减少代码量
在这个项目里,代码量大不是核心问题。
必须存在的代码包括:
| 代码类型 | 是否能删 |
|---|---|
| 2.3/2.4 兼容逻辑 | 不能马上删 |
| ReBAC 新权限模型 | 不能删 |
| RBAC/ReBAC 双写和同步 | 迁移期不能删 |
| OpenFGA fallback / compensation | 不能删 |
| 权限 UI 展示和授权操作 | 不能删 |
| 迁移脚本 / reconcile 脚本 | 不能删 |
| 回归测试 | 不该删 |
所以重构后的总代码量可能:
| 项目 | 预期 |
|---|---|
| 总 LOC | 基本不变,甚至短期增加 5%-15% |
| 文件数量 | 增加 |
| 单个核心文件 LOC | 降低 |
| 单个函数 LOC | 降低 |
| 单次改动理解成本 | 降低 |
| 兼容逻辑删除成本 | 降低 |
重构目标不是“少写代码”,而是:
> 让必须存在的复杂度各就各位。
## 8. 重构优先级
### P0:不要推倒重写
原因:
- 多版本客户仍然存在。
- 迁移数据量大。
- 当前已有测试保护。
- 大重写会破坏已经被测试锁住的兼容行为。
### P1:瘦 `resource_permission.py`
目标文件:
`src/backend/bisheng/permission/api/endpoints/resource_permission.py`
建议拆出:
```text
ResourcePermissionApplicationService
GrantPolicyService
RelationModelService
PermissionBindingService
PermissionCompatibilityService
PermissionEntryAssembler
```
API contract 保持不变。
重构前:
```text
endpoint = HTTP + 业务规则 + 兼容逻辑 + 展示组装
```
重构后:
```text
endpoint = HTTP 入参/出参
application service = 用例编排
policy service = 授权规则
compatibility service = 旧版本兼容
assembler = 前端展示结果组装
```
### P2:阶段化 `FineGrainedPermissionService`
目标文件:
`src/backend/bisheng/permission/domain/services/fine_grained_permission_service.py`
当前核心函数:
```text
get_effective_permission_ids_async: 120 行,29 个分支
```
建议拆成:
```text
build_resource_lineage
read_permission_tuples
match_relation_model_bindings
filter_legacy_subscription_tuples
apply_implicit_permissions
resolve_effective_permission_ids
```
### P3:拆 `KnowledgeSpaceService`,保留 facade
目标文件:
`src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`
建议:
```text
KnowledgeSpaceService # 保留 facade
KnowledgeSpaceQueryService # 列表/广场/详情
KnowledgeSpaceFileService # 上传/文件夹/文件管理
KnowledgeSpaceDownloadService # 下载/批量下载
KnowledgeSpaceMemberService # 成员/角色
KnowledgeSpaceSubscriptionService # 订阅/申请
```
### P4:前端抽 headless hooks
目标:
- Client 和 Platform UI 可以不同。
- 权限业务规则不要重复实现。
建议抽:
```text
usePermissionListModel
usePermissionGrantModel
useSubjectDepartmentSearch
```
### P5:角色服务下次改动时顺手拆
目标文件:
`src/backend/bisheng/role/domain/services/role_service.py`
建议拆:
```text
RoleQueryService
RoleScopeResolver
RoleCreatorResolver
RoleMutationPolicy
```
## 9. 后续质量红线
建议设置以下红线,不要求一次性全部达成,但作为后续增量开发标准。
| 指标 | 红线 |
|---|---:|
| API endpoint 单函数 | 不超过 70 行 |
| domain service 单函数 | 不超过 100 行 |
| 单个 service 文件 | 超过 1,500 行必须评估拆分 |
| 新增兼容逻辑 | 必须注明支持版本和删除条件 |
| 新增 fallback | 必须有测试覆盖 |
| 前端权限判断 | 不允许 Client / Platform 双写新规则 |
| 权限规则变更 | 必须覆盖 happy path + denied path |
| 迁移逻辑 | 必须有 reconcile 或幂等保护 |
## 10. 最终判断
这批代码不应该简单评价为“史山”。
更准确的评价:
> 代码量大是业务和迁移复杂度导致的,测试投入较高,整体仍可维护。当前最主要风险不是 LOC,而是复杂度集中在少数 endpoint 和 facade service 中。如果继续以补 if、补 fallback 的方式推进,未来会滑向“准史山”;如果现在做分层治理,成本仍然可控。
最优先行动:
1. 不重写。
2. 保持 API contract 不变。
3. 先瘦 `resource_permission.py`
4. 把兼容逻辑集中命名并标注删除条件。
5. 阶段化 `FineGrainedPermissionService` 的权限解析流程。
6. 前端抽 headless 权限逻辑,避免 Client / Platform 漂移。
一句话总结:
> 重构不会让代码量明显减少,但会让每次改动需要理解的代码量明显减少。
@@ -0,0 +1,199 @@
# 权限迁移与数据丢失排查结论
日期:2026-04-27
## 结论
当前“丢了不少书籍”的主要表现更像是权限迁移后资源不可见,而不是 F006 权限迁移脚本批量删除了 MySQL 中的知识库或知识文件。
109 服务器上,`knowledge` 表没有发现备份后被删除的记录;`knowledgefile` 表只确认有 1 条备份中存在、当前库缺失的历史记录:`id=176 / PDF_TestPage.pdf / knowledge_id=49`。该记录在 `2026-04-21 14:57:12` 被删除,早于 F006 权限迁移执行时间。
权限层面存在明确问题:OpenFGA failed_tuple 队列中有知识空间相关的失败写入,集中在把 `department:X#member` 写成 `knowledge_space#owner`OpenFGA 模型拒绝该 tuple。这类问题会造成资源仍在数据库中,但用户或部门看不到。
## 已确认的迁移执行记录
### 109 服务器
F006 在后端启动过程中自动触发,不是从 shell 手动执行。
- 执行时间:`2026-04-25 15:27:05`
- checkpoint`completed_step = 8`
- checkpoint 时间:`2026-04-25T15:27:11.106508`
- 统计:
- Step 1 super_admin3
- Step 2 user_group16
- Step 3 role_access0
- Step 4 space_channel_member115
- Step 5 resource owners126
- Step 6 folder hierarchy220
- Step 7 user_department22
- Step 8 groupresource137
- 当前 Alembic 版本:`f032_workbench_subscription_web_menu_backfill`
### 114 服务器
114 上存在分阶段执行记录。
- `2026-04-22 19:55:50`:旧版本 F006 跑到 Step 6,写入 124 tuples
- `2026-04-25 15:10:59`checkpoint 显示 Step 8 完成
- 当前 Alembic 版本:`f030_tenant_root_dept_id_backfill`
## MySQL 数据对账结果
### 1094/21 备份 vs 当前库
备份文件:`/data/bisheng-mysql-recovery-20260421-113612.sql.gz`
`knowledge`
- 备份:29 条,ID 范围 `1..50`
- 当前:49 条,ID 范围 `1..70`
- 备份中存在但当前缺失:0 条
`knowledgefile`
- 备份:201 条,ID 范围 `1..244`
- 当前:221 条,ID 范围 `1..270`
- 备份中存在但当前缺失:1 条
缺失记录:
```text
id=176
knowledge_id=49
file_name=PDF_TestPage.pdf
status=3
object_name=original/176.pdf
create_time=2026-04-20 19:27:14
```
### 109binlog 删除记录
检查窗口:`2026-04-21 11:36:12``2026-04-27 23:59:59`
- `knowledge` DELETE0
- `knowledgefile` DELETE6
6 条 `knowledgefile` DELETE 中,只有 `id=176` 是备份已有且当前缺失的记录;其余为备份后新建又删除的文件。`id=176` 的删除时间是 `2026-04-21 14:57:12`,早于 F006 在 `2026-04-25` 的执行时间。
`2026-04-24` 之后没有发现 `knowledge``knowledgefile` 的 DELETE。
### 114binlog 检查
检查窗口:`2026-04-22 00:00:00``2026-04-27 23:59:59`
- `knowledge` DELETE0
- `knowledgefile` DELETE0
114 未找到可用于做 ID 级对账的 SQL 备份文件。
## 权限层问题
### 109 failed_tuple
当前 `failed_tuple` 状态:
- `succeeded`241
- `dead`41
dead 记录集中在:
- `owner -> knowledge_space:53`
- `owner -> knowledge_space:55`
- `owner -> knowledge_space:59`
错误类型:
```text
department:X#member is not an allowed type restriction for knowledge_space#owner
```
这说明迁移或后续权限写入曾尝试把部门成员关系写成知识空间 owner。该 tuple 不符合 OpenFGA authorization model,因此无法写入。结果是相关知识空间可能在 MySQL 中存在,但部门/用户侧不可见或权限不完整。
涉及的知识空间包括:
- `knowledge_space:53``yifeitest2`
- `knowledge_space:55``yifeitest3`
- `knowledge_space:59``毕昇测试环境SSO的知识空间`
### 114 failed_tuple
当前 `failed_tuple` 状态:
- `pending`83
- `succeeded`70
pending 中有较多旧的 `FGAClient not available` 失败记录,也包含指向历史测试资源或已不存在资源的 tuple。114 需要先做有效性清理,再重放仍然有效的权限。
## 脚本风险点
### 1. checkpoint 与 Redis completed marker 状态不一致
F006 runner 使用两个完成信号:
- 文件 checkpoint`/tmp/bisheng-permission-migration/migration_f006_checkpoint.json`
- Redis marker`migration:f006:completed`
现场看到 checkpoint 已经完成到 Step 8,但 Redis completed marker 之前检查为空。这样会导致“脚本看起来跑过”和“runner 判断是否完成”的状态来源不一致。
### 2. checkpoint 跳过粒度太粗
F006 每个 Step 成功后保存 checkpoint。后续如果发现某个 Step 的语义映射有问题,重启会直接跳过已完成 Step,不会自动补写或纠错。
### 3. FGAWriteError 中 `cannot write` 被当成成功
代码位置:
`src/backend/bisheng/permission/migration/migrate_rbac_to_rebac.py`
`_write_singles()` 中把包含 `already exists``cannot write` 的错误都算作成功:
```python
if 'already exists' in err_lower or 'cannot write' in err_lower:
written += 1
```
`already exists` 可以视为幂等成功,但 `cannot write` 可能是真实模型不允许写入,不应该算作成功。线上 failed_tuple 中的知识空间 owner 错误就属于这类语义错误风险。
### 4. verify 覆盖不足
F006 的 verify 是抽样验证,并且主要比较 `can_read`。它无法证明所有知识库、知识空间、文件夹、知识文件的 owner/manager/editor/viewer/parent 关系都完整迁移。
## 本地测试结果
已运行:
```bash
src/backend/.venv/bin/python -m pytest \
src/backend/test/test_f006_permission_migration.py \
src/backend/test/test_reconcile_role_access_fga_entrypoint.py
```
结果:
```text
61 passed
```
现有测试能覆盖 F006 映射、流程、checkpoint、基础幂等场景,但没有覆盖线上发现的这些关键风险:
- `cannot write` 不应被视为成功
- failed_tuple 中无效 owner tuple 的修复策略
- checkpoint 完成但 Redis marker 缺失
- 全量资源可见性对账
- 知识空间、文件夹、知识文件 parent 链路的端到端可见性
## 下一步修复建议
1. 增加只读对账脚本,从 MySQL 旧权限表和当前资源表生成“应有 OpenFGA tuple”,与当前 OpenFGA tuple 做 diff。
2. 对 109 先处理 `knowledge_space:53/55/59` 的无效 owner failed_tuple,按正确 relation 重建权限,不要直接 retry 原 tuple。
3. 对 114 先清理 pending failed_tuple:区分仍有效资源、已删除资源、历史测试资源,再重放有效 tuple。
4. 修改 F006 `_write_singles()`,只把 `already exists` 视为幂等成功;`cannot write` 必须进入 failed_tuple 并让迁移失败或至少显式报警。
5. 给 F006 增加全量 verify 模式,至少输出:
- MySQL 资源总数
- 应有 tuple 数
- OpenFGA 实际 tuple 数
- 缺失 tuple 明细
- 无效/stale tuple 明细
6. 明确 checkpoint 与 Redis completed marker 的权威来源,避免只完成 checkpoint 但没有 completed marker 的半完成状态。
@@ -1,9 +1,10 @@
"""Regression tests for permission relation-model bindings."""
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
def mock_admin_user():
@@ -372,6 +372,13 @@ export default function KnowledgeFile() {
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
const canManageKb = (el: any) =>
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasPermissionId(permIds, el.id, permissionId));
const isLibraryBusy = (el: any) =>
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
const canCopy = (el: any) =>
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
const hasRowActions = (el: any) =>
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
// Enable polling during copying
useEffect(() => {
@@ -524,7 +531,9 @@ export default function KnowledgeFile() {
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
<TableHead>{t('updateTime')}</TableHead>
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
<TableHead className="text-right">{t('operations')}</TableHead>
{showOperationsColumn && (
<TableHead className="text-right">{t('operations')}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
@@ -575,13 +584,13 @@ export default function KnowledgeFile() {
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
</TableCell>
<TableCell className="text-right">
{showOperationsColumn && <TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Select
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
key={`${el.id}-${modalKey}`}
open={selectOpenId === el.id}
onOpenChange={(isOpen) => {
if (el.state === 2 || el.state === 0) return;
if (isLibraryBusy(el) || !hasRowActions(el)) return;
if (copyLoadingId !== el.id) {
setSelectOpenId(isOpen ? el.id : null);
} else if (!isOpen) {
@@ -600,9 +609,7 @@ export default function KnowledgeFile() {
setModalKey(prev => prev + 1);
break;
case 'copy':
canUseCopy(el) &&
el.state === KnowledgeBaseStatus.Published &&
handleCopy(el);
canCopy(el) && handleCopy(el);
break;
case 'set':
canEdit(el) && handleOpenSettings(el);
@@ -629,10 +636,10 @@ export default function KnowledgeFile() {
</div>
</>
) : (
<Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
)}
</SelectTrigger>
<SelectContent
{hasRowActions(el) && <SelectContent
onClick={(e) => {
e.stopPropagation();
}}
@@ -646,28 +653,21 @@ export default function KnowledgeFile() {
</div>
</SelectItem>
)}
<Tip content={!canUseCopy(el) && t('noOperationPermission')} side='top'>
{canCopy(el) && (
<SelectItem
showIcon={false}
value="copy"
className="data-[disabled]:pointer-events-auto"
disabled={
!canUseCopy(el) ||
el.state !== KnowledgeBaseStatus.Published ||
copyLoadingId === el.id
}
disabled={copyLoadingId === el.id}
>
<div className="flex gap-2 items-center" >
<Copy className="w-4 h-4" />
{t('lib.copy', { ns: 'bs' })}
</div>
</SelectItem>
</Tip>
<Tip content={!canEdit(el) && t('noOperationPermission')} side='top'>
)}
{canEdit(el) && (
<SelectItem
value="set"
disabled={!canEdit(el)}
className="data-[disabled]:pointer-events-auto"
showIcon={false}
>
<div className="flex gap-2 items-center">
@@ -675,24 +675,22 @@ export default function KnowledgeFile() {
{t('settings')}
</div>
</SelectItem>
</Tip>
<Tip content={!canDelete(el) && t('noOperationPermission')} side='top'>
)}
{canDelete(el) && (
<SelectItem
value="delete"
showIcon={false}
className="data-[disabled]:pointer-events-auto"
disabled={!canDelete(el)}
>
<div className="flex gap-2 items-center">
<Trash2 className="w-4 h-4" />
{t('delete')}
</div>
</SelectItem>
</Tip>
</SelectContent>
</Select>
)}
</SelectContent>}
</Select>}
</div>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
@@ -6,13 +6,11 @@ import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDial
import { hasPermissionId, usePermissionIds } from "@/components/bs-comp/permission/usePermissionLevels";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
import { Input, SearchInput, Textarea } from "@/components/bs-ui/input";
import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge";
import AutoPagination from "@/components/bs-ui/pagination/autoPagination";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/bs-ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table";
import { useToast } from "@/components/bs-ui/toast/use-toast";
import { QuestionTooltip } from "@/components/bs-ui/tooltip";
import Tip from "@/components/bs-ui/tooltip/tip";
import { userContext } from "@/contexts/userContext";
import { copyQaDatabase, createFileLib, deleteFileLib, readFileLibDatabase, updateKnowledge } from "@/controllers/API";
import { getKnowledgeModelConfig } from "@/controllers/API/finetune";
@@ -319,6 +317,13 @@ export default function KnowledgeQa(params) {
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
const canManageKb = (el: any) =>
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasPermissionId(permIds, el.id, permissionId));
const isLibraryBusy = (el: any) =>
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
const canCopy = (el: any) =>
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
const hasRowActions = (el: any) =>
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
useEffect(() => {
const todos = datalist.filter(lib => lib.state === KnowledgeBaseStatus.Copying);
@@ -417,7 +422,9 @@ export default function KnowledgeQa(params) {
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
<TableHead>{t('updateTime')}</TableHead>
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
<TableHead className="text-right">{t('operations')}</TableHead>
{showOperationsColumn && (
<TableHead className="text-right">{t('operations')}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
@@ -440,7 +447,6 @@ export default function KnowledgeQa(params) {
<div>
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2 flex items-center gap-2">
{el.name}
<PermissionBadge level={permLevels[String(el.id)]} />
</div>
<QuestionTooltip
content={el.description || ''}
@@ -460,13 +466,13 @@ export default function KnowledgeQa(params) {
<TableCell className="max-w-[300px] break-all">
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
</TableCell>
<TableCell className="text-right">
{showOperationsColumn && <TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Select
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
key={`${el.id}-${modalKey}`}
open={selectOpenId === el.id}
onOpenChange={(isOpen) => {
if (el.state === 2 || el.state === 0) return;
if (isLibraryBusy(el) || !hasRowActions(el)) return;
if (copyLoadingId !== el.id) {
setSelectOpenId(isOpen ? el.id : null);
} else if (!isOpen) {
@@ -482,9 +488,7 @@ export default function KnowledgeQa(params) {
setModalKey(prev => prev + 1);
break;
case 'copy':
canUseCopy(el) &&
el.state === KnowledgeBaseStatus.Published &&
handleCopy(el);
canCopy(el) && handleCopy(el);
break;
case 'set':
canEdit(el) && handleOpenSettings(el);
@@ -509,10 +513,10 @@ export default function KnowledgeQa(params) {
</div>
</>
) : (
<Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
)}
</SelectTrigger>
<SelectContent
{hasRowActions(el) && <SelectContent
onClick={(e) => e.stopPropagation()}
className="z-50 overflow-visible"
>
@@ -524,26 +528,21 @@ export default function KnowledgeQa(params) {
</div>
</SelectItem>
)}
<Tip content={!canUseCopy(el) && t('noPermission')} side='top'>
{canCopy(el) && (
<SelectItem
showIcon={false}
value="copy"
disabled={
!canUseCopy(el) ||
el.state !== KnowledgeBaseStatus.Published ||
copyLoadingId === el.id
}
disabled={copyLoadingId === el.id}
>
<div className="flex gap-2 items-center">
<Copy className="w-4 h-4" />
{t('lib.copy', { ns: 'bs' })}
</div>
</SelectItem>
</Tip>
<Tip content={!canEdit(el) && t('noPermission')} side='top'>
)}
{canEdit(el) && (
<SelectItem
value="set"
disabled={!canEdit(el)}
showIcon={false}
>
<div className="flex gap-2 items-center">
@@ -551,23 +550,22 @@ export default function KnowledgeQa(params) {
{t('setting')}
</div>
</SelectItem>
</Tip>
<Tip content={!canDelete(el) && t('noPermission')} side='top'>
)}
{canDelete(el) && (
<SelectItem
value="delete"
showIcon={false}
disabled={!canDelete(el)}
>
<div className="flex gap-2 items-center">
<Trash2 className="w-4 h-4" />
{t('delete')}
</div>
</SelectItem>
</Tip>
</SelectContent>
</Select>
)}
</SelectContent>}
</Select>}
</div>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
@@ -246,6 +246,7 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
// Batch Download
const [isDownloading, setIsDownloading] = useState(false);
const canBatchEditTags = canEditKb && selectedFileObjs.length > 0 && selectedFileObjs.every(file => file.status === 2);
const hasRowActions = canEditKb || canDeleteKb;
const handleBatchDownload = async () => {
setIsDownloading(true);
try {
@@ -401,7 +402,8 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
{isDownloading ? <LoadingIcon className="h-4 w-4 mr-1" /> : <Download size={16} />}
<span className="hidden sm:inline">{t('download', { ns: 'bs' })}</span>
</Button>
<Tip content={!canEditKb ? t('noOperationPermission') : !canBatchEditTags ? t('tagOperationRequiresCompletedFiles') : ''} side='bottom'>
{canEditKb && (
<Tip content={!canBatchEditTags ? t('tagOperationRequiresCompletedFiles') : ''} side='bottom'>
<KnowledgeTagSelect
knowledgeId={id}
fileIds={selectedFileObjs.map(f => Number(f.id))}
@@ -418,30 +420,27 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
<span className="hidden sm:inline">{t('tags')}</span>
</Button>
</KnowledgeTagSelect>
</Tip>
<Tip content={!canDeleteKb && t('noOperationPermission')} side='bottom'>
</Tip>
)}
{canDeleteKb && (
<Button
variant="outline"
onClick={handleBatchDelete}
disabled={!canDeleteKb}
className="flex items-center gap-1 disabled:pointer-events-auto h-9 px-2 sm:px-4"
>
<Trash2 size={16} />
<span className="hidden sm:inline">{t('delete')}</span>
</Button>
</Tip>
{hasSelectedFailedFiles && (
<Tip content={!canEditKb && t('noOperationPermission')} side='bottom'>
)}
{canEditKb && hasSelectedFailedFiles && (
<Button
variant="outline"
onClick={handleBatchRetry}
disabled={!canEditKb}
className="flex items-center gap-1 disabled:pointer-events-auto h-9 px-2 sm:px-4"
>
<RotateCw size={16} />
<span className="hidden sm:inline">{t('retry')}</span>
</Button>
</Tip>
)}
</div>
)}
@@ -453,15 +452,16 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
setSelectedFileObjs([]);
setIsAllSelected(false);
}} />
<Button
variant="outline"
onClick={() => setMetadataOpen(true)}
disabled={!canEditKb}
className="px-2 md:px-4 whitespace-nowrap h-9"
>
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-0 md:mr-1" />
<span className="hidden md:inline">{t('metaData')}</span>
</Button>
{canEditKb && (
<Button
variant="outline"
onClick={() => setMetadataOpen(true)}
className="px-2 md:px-4 whitespace-nowrap h-9"
>
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-0 md:mr-1" />
<span className="hidden md:inline">{t('metaData')}</span>
</Button>
)}
{canEditKb && (
<Link to={`/filelib/upload/${id}`}>
<Button className="px-4 md:px-8 h-9">{t('uploadFile')}</Button>
@@ -597,7 +597,9 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
</DropdownMenu>
</div>
</TableHead>
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
{hasRowActions && (
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
@@ -660,14 +662,12 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
<TableCell>
<StatusIndicator status={el.status} remark={el.remark} />
</TableCell>
<TableCell className="text-right">
{hasRowActions && <TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{el.status === 3 && (
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
{canEditKb && el.status === 3 && (
<Button
variant="ghost"
size="icon"
disabled={!canEditKb}
onClick={(e) => {
e.stopPropagation();
handleRetry([el]); // Single retry passes complete object
@@ -677,13 +677,8 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
>
<RotateCw size={16} />
</Button>
</Tip>
)}
<Tip
content={!canDeleteKb && t('noOperationPermission')}
side='top'
styleClasses="-translate-x-6"
>
{canDeleteKb && (
<Button
variant="ghost"
size="icon"
@@ -692,14 +687,13 @@ export default function Files({ onPreview, canEditKb = false, canDeleteKb = fals
e.stopPropagation();
handleDelete(el.id);
}}
disabled={!canDeleteKb}
title={t('delete')}
>
<Trash2 size={16} />
</Button>
</Tip>
)}
</div>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
@@ -5,7 +5,6 @@ import { Dialog, DialogContent } from '@/components/bs-ui/dialog';
import { SearchInput } from '@/components/bs-ui/input';
import AutoPagination from '@/components/bs-ui/pagination/autoPagination';
import { toast } from "@/components/bs-ui/toast/use-toast";
import Tip from "@/components/bs-ui/tooltip/tip";
import ShadTooltip from "@/components/ShadTooltipComponent";
import { addMetadata, delChunkApi, getFileBboxApi, getFilePathApi, getKnowledgeChunkApi, getKnowledgeDetailApi, getMetaFile, readFileByLibDatabase, saveUserMetadataApi, updateChunkApi } from '@/controllers/API';
import { captureAndAlertRequestErrorHoc } from '@/controllers/request';
@@ -830,23 +829,23 @@ export default function Paragraphs({ fileId, onBack, canEditKb = false, canDelet
disabled={!selectedFileId}
/>
</div>
{canEditKb && (
<Button
variant="outline"
onClick={handleMetadataClick}
disabled={!canEditKb}
className="px-4 whitespace-nowrap disabled:pointer-events-auto"
className="px-4 whitespace-nowrap"
>
<ClipboardPenLine size={16} strokeWidth={1.5} className="mr-1" />
{t('metadialog.title')}
</Button>
<Tip content={!canEditKb && t('common.noPermission')} side='top'>
)}
{canEditKb && (
<Button
disabled={!canEditKb}
onClick={handleAdjustSegmentation}
className={`px-4 whitespace-nowrap disabled:pointer-events-auto`}>
className={`px-4 whitespace-nowrap`}>
{t('segment.adjustStrategy')}
</Button>
</Tip>
)}
</div>
</div>
@@ -5,7 +5,6 @@ import { Checkbox } from "@/components/bs-ui/checkBox";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
import { Switch } from "@/components/bs-ui/switch";
import { useToast } from "@/components/bs-ui/toast/use-toast";
import Tip from "@/components/bs-ui/tooltip/tip";
import { downloadFile, formatDate } from "@/util/utils";
import { ArrowLeft, SquareCheckBig, SquareX, Trash2 } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
@@ -244,6 +243,7 @@ export default function QasPage() {
const { page, pageSize, data: datalist, total, loading, setPage, search, reload, refreshData } = useTable({}, (param) =>
getQaList(id, param)
)
const hasSelectableActions = canEditKb || canDeleteKb;
useEffect(() => {
let cancelled = false;
@@ -496,38 +496,36 @@ export default function QasPage() {
<span className="text-gray-700 text-sm font-black pl-4 dark:text-white truncate max-w-80">{title}</span>
</div>
</div>
<div className={selectedItems.length ? 'visible' : 'invisible'}>
<Tip content={!canDeleteKb && t('noOperationPermission')} side='top'>
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canDeleteKb} onClick={handleDeleteSelected}>
{selectedItems.length > 0 && hasSelectableActions && (
<div>
{canDeleteKb && (
<Button variant="outline" className="ml-2" onClick={handleDeleteSelected}>
<Trash2 className="mr-2 h-4 w-4" ></Trash2> {t('delete')}
</Button>
</Tip>
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canEditKb} onClick={handleDisableSelected}>
)}
{canEditKb && (
<Button variant="outline" className="ml-2" onClick={handleDisableSelected}>
<SquareX className="mr-2 h-4 w-4" /> {t('disable')}
</Button>
</Tip>
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
<Button variant="outline" className="disabled:pointer-events-auto ml-2" disabled={!canEditKb} onClick={handleEnableSelected}>
)}
{canEditKb && (
<Button variant="outline" className="ml-2" onClick={handleEnableSelected}>
<SquareCheckBig className="mr-2 h-4 w-4" /> {t('enable')}
</Button>
</Tip>
)}
</div>
)}
<div className="flex justify-between items-center mb-4">
<div className="flex gap-4 items-center">
<SearchInput placeholder={t('qaContent')} onChange={(e) => search(e.target.value)}></SearchInput>
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
<Button variant="outline" disabled={!canEditKb} className="disabled:pointer-events-auto px-8" onClick={() => importRef.current.open()}>{t('import')}</Button>
</Tip>
<Button variant="outline" className="px-8" onClick={() => {
{canEditKb && <Button variant="outline" className="px-8" onClick={() => importRef.current.open()}>{t('import')}</Button>}
{canEditKb && <Button variant="outline" className="px-8" onClick={() => {
getQaFile(id).then(res => {
const fileUrl = res.file_list[0];
downloadFile(checkSassUrl(fileUrl), `${title} ${formatDate(new Date(), 'yyyy-MM-dd')}.xlsx`);
})
}}>{t('export')}</Button>
<Tip content={!canEditKb && t('noOperationPermission')} side='top'>
<Button className="disabled:pointer-events-auto px-8" disabled={!canEditKb} onClick={() => editRef.current.open()}>{t('createQA')}</Button>
</Tip>
}}>{t('export')}</Button>}
{canEditKb && <Button className="px-8" onClick={() => editRef.current.open()}>{t('createQA')}</Button>}
</div>
</div>
</div>
@@ -535,27 +533,29 @@ export default function QasPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">
{hasSelectableActions && <TableHead className="w-8">
<Checkbox checked={selectAll} onCheckedChange={handleSelectAll} />
</TableHead>
</TableHead>}
<TableHead className="w-[340px]">{t('question')}</TableHead>
<TableHead className="w-[340px]">{t('answer')}</TableHead>
<TableHead>{t('type')}</TableHead>
<TableHead>{t('updateTime')}</TableHead>
<TableHead>{t('createUser')}</TableHead>
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
{hasSelectableActions && (
<TableHead className="text-right pr-6">{t('operations')}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{datalist.map(el => (
<TableRow key={el.id} className={canEditKb ? "hover:bg-gray-100" : ""}>
<TableCell className="font-medium" onClick={(e) => e.stopPropagation()}>
{hasSelectableActions && <TableCell className="font-medium" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.includes(el.id)}
onCheckedChange={() => handleCheckboxChange(el.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
</TableCell>}
<TableCell
className="font-medium cursor-pointer"
@@ -584,22 +584,15 @@ export default function QasPage() {
</TableCell>
<TableCell>{el.update_time.replace('T', ' ')}</TableCell>
<TableCell>{el.user_name}</TableCell>
<TableCell className="text-right">
{hasSelectableActions && <TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center">
{canEditKb && <div className="flex items-center">
{el.status !== 2 && (
<Tip
content={!canEditKb && t('noOperationPermission')}
side='top'>
<div>
<Switch
checked={el.status === 1}
disabled={!canEditKb}
className="disabled:pointer-events-auto"
onCheckedChange={(bln) => handleStatusClick(el.id, bln)}
/>
</div>
</Tip>
<Switch
checked={el.status === 1}
className="disabled:pointer-events-auto"
onCheckedChange={(bln) => handleStatusClick(el.id, bln)}
/>
)}
{el.status === 2 && (
<span className="text-sm">{t('processing')}</span>
@@ -607,16 +600,12 @@ export default function QasPage() {
{el.status === 3 && (
<span className="text-sm">{t('notEnabled')}</span>
)}
</div>
<Tip
content={!canDeleteKb && t('noOperationPermission')}
styleClasses="-translate-x-6"
side='top'>
</div>}
{canDeleteKb && (
<Button
variant="ghost"
size="icon"
className="disabled:pointer-events-auto"
disabled={!canDeleteKb}
onClick={(e) => {
e.stopPropagation();
handleDelete(el.id);
@@ -624,9 +613,9 @@ export default function QasPage() {
>
<Trash2 size={16} />
</Button>
</Tip>
)}
</div>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>