mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-01 15:32:50 +08:00
fix(F034): address beta4 test feedback for knowledge-space move & folder upload
Backend: - move dup check: normalise NULL/"" file_level_path so a same-name folder at the target space root is detected (NULL == "" is false in SQL) - move: enforce upload_file on the move target (folder / space root), same-space and cross-space — server now rejects moving into a no-permission target (reverses decision-8) - list_uploadable_spaces: list candidates via can_read then filter by the fine-grained upload_file check, so spaces granted upload via a custom permission template (viewer-tier relation) are no longer missed Frontend (client): - disable per-item 'move' menu for uploading placeholders - filter uploading placeholders by active space (no cross-space leak) - probe move_folder separately from move_file; gate per-item and batch move by the matching permission - folder upload: toast skipped oversize / unsupported files (hidden stay silent) Tests: move (15) + uploadable (10) green.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# F034 测试反馈修复 — 第 1 轮
|
||||
|
||||
> 来源:beta4 手动测试(2026-06-16)反馈的 9 个问题 + 1 个状态确认。
|
||||
> 关联:[spec.md](./spec.md) · [design.md](./design.md) · [tasks.md](./tasks.md)
|
||||
> 本文档定位:这一轮所有反馈的**集中记录 + 根因 + 方案 + 状态**,逐项推进时在此回写。
|
||||
|
||||
---
|
||||
|
||||
## 0. 状态总览
|
||||
|
||||
| # | 问题(一句话) | 层 | 根因已定 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 上传中文件的单项菜单「移动」没置灰(批量是对的) | 前端 | ✅ | ✅ 已改(待验证) |
|
||||
| 2 | 跨空间移文件夹到**目标空间根目录**,同名校验漏判 | 后端 | ✅ | ✅ 已改(待补测试) |
|
||||
| 3 | 上传 1 个文件后切到别的空间,仍显示该上传中文件 | 前端 | ✅ | ✅ 已改(待验证) |
|
||||
| 4 | 有「移动文件夹」权限、没「移动文件」权限时,文件夹移动按钮被误置灰 | 前端 | ✅ | ✅ 已改(待验证) |
|
||||
| 5 | 目标空间无上传权限,文件却能移入 | 后端 | ✅ | 🟡 **待拍板**(推翻决策8) |
|
||||
| 6 | 「移动到」弹窗左侧漏掉「用权限模板授权」的空间 | 后端 | ✅ | 🔵 方案 ultrathink 中(前提验证后台进行) |
|
||||
| 7 | 拖拽上传文件夹,超大文件被静默过滤、无提示 | 前端 | ✅ | 🟡 **待拍板**(推翻 AC-27) |
|
||||
| 8 | 上传文件夹超 10 级,前端无 toast | 前端 | ⚠️ 链路看似完整 | 🔴 待复现 |
|
||||
| 9 | 上传文件夹超空间容量,没拦住 | 后端 | ⚠️ 代码看似正确 | 🔴 待复现 |
|
||||
| 10 | 确认有无「重建中」状态 | — | — | ✅ 已答 |
|
||||
|
||||
图例:✅ 完成 / 🟡 等产品拍板 / 🔵 方案设计中 / 🔴 等复现信息
|
||||
|
||||
**#10 答复**:有这个状态。文件状态枚举 `REBUILDING`(值=4),列表 UI 上显示文案是**「处理中」**(i18n key `com_knowledge.rebuilding_status`,文案就是"处理中",不叫"重建中")。跨空间移动迁移期间走的就是它。区分:解析中=PROCESSING、排队中=WAITING。
|
||||
|
||||
---
|
||||
|
||||
## 1. 已修复(待验证回归)
|
||||
|
||||
### #1 上传中文件单项菜单「移动」应置灰
|
||||
- **根因**:批量移动用 `selectionHasUploading` 把含上传中项的选择判为不可移;单项行/卡片菜单的「移动」只判了权限 `!canMove`,漏接 `isUploading`(该标志本地已算好,仅用在 draggable 上)。
|
||||
- **改动**:
|
||||
- `SpaceDetail/FileTable.tsx` FileRow 移动菜单 `disabled={!canMove || isUploading}`
|
||||
- `SpaceDetail/FileCard.tsx` 两处移动菜单(桌面 + H5)同改
|
||||
- **覆盖**:AC-13 反向约束(上传中=无后端身份,不可移)。
|
||||
|
||||
### #2 跨空间移文件夹到目标空间根目录,同名校验漏判
|
||||
- **根因**:`move_items` 的文件夹同名查重,移到**目标空间根目录**时比较 `file_level_path == ""`;但历史/部分数据的根级路径存的是 `NULL`,SQL 里 `NULL == ''` 恒为假 → 目标根目录已有的同名文件夹查不到 → 后端返回空 `invalid` → 前端忠实地按"成功"刷新(看着像没校验)。前端链路完全正确,无需改。
|
||||
- **改动**:`knowledge_space_service.py` `move_items` dup 查询,当 `target_level_path == ""` 时用 `SpaceFileDao._root_path_filter()`(`== "" OR IS NULL`)归一;非根目录维持精确相等。
|
||||
- **待办**:补单测「移到目标空间根目录、根级已存在 NULL 路径同名文件夹 → name_conflict」(`test/knowledge/test_knowledge_space_move.py`)。
|
||||
- **覆盖**:AC-12。
|
||||
|
||||
### #3 上传中文件切空间仍显示
|
||||
- **根因**:上传中占位文件是 `useFileUpload` 的本地 state `uploadingFiles`,占位对象带了 `spaceId` 但渲染时 `displayFiles` 无条件 `...uploadingFiles`,从不按当前空间过滤;切空间不清这个 state。
|
||||
- **改动**:`SpaceDetail/index.tsx` `displayFiles` 改为 `...uploadingFiles.filter(f => String(f.spaceId) === String(space.id))`(视图层按空间隔离,不打断后台上传)。
|
||||
|
||||
### #4 有 move_folder 无 move_file 时文件夹移动被误置灰
|
||||
- **根因**:前端只探测了 `move_file` 一个权限,存进单一 `canMoveFile`,无差别地同时用于文件和文件夹的移动门控;从未探测 `move_folder`。批量移动同理只用了 `canMoveFile`。(后端 `move_items` 逻辑正确:文件夹用 `move_folder`、文件用 `move_file`,已测过。)
|
||||
- **改动**(`SpaceDetail/index.tsx` + `FileTable.tsx`):
|
||||
- 新增 `canMoveFolder` 状态,Promise.allSettled 增加 `move_folder` 探测。
|
||||
- 单项:FileCard `canMove={file.type===FOLDER ? canMoveFolder : canMoveFile}`;FileTable props 拆成 `canMoveFile`/`canMoveFolder`,每行按 `file.type` 选。
|
||||
- 批量:`canBatchMove = 选中含文件夹则需 canMoveFolder ∧ 选中含文件则需 canMoveFile ∧ 非上传中 ∧ 有选择`。
|
||||
- **覆盖**:AC-05/AC-06 的前端门控。
|
||||
- **备注**:诊断另发现潜在数据侧隐患——`relation_model_backfill.py` 只对系统档(is_system)回填新权限,**未被回填的自定义关系模型不会拿到 move_file/move_folder**。前端修完后,若实测仍有"连 move_folder 都没有"的情况,需查该用户授权是否走未回填的自定义档(单列为 R2 待观察项,本轮不动)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 待产品拍板(推翻了之前已拍板的决策)
|
||||
|
||||
### #5 目标空间无上传权限却能移入 —— 推翻 design 决策8
|
||||
- **现状**:当初产品(2026-06-10)**明确拍板**"服务端不校验目标空间/文件夹的 upload_file 权限,目标过滤仅是弹窗展示行为"(design 决策8)。所以 `move_items` 对 target 侧只校验:存在性 + 同租户 + 文件夹类型,**确实没有** upload_file 校验。
|
||||
- **本轮诉求**:加服务端目标校验,无上传权限不允许移入。
|
||||
- **拟定方案**:双管齐下 —— ① 弹窗左侧本就只显示有上传权限的空间(= #6 修完);② `move_items` 解析完 target 后(`:3556` 之后、进入逐项循环之前)加一次目标 upload_file 校验,复刻 `add_file` 开头口径(`_require_permission_id("folder", target_folder_id, "upload_file", ...)` 或对空间根 `_require_permission_id("knowledge_space", target_space_id, "upload_file")`)。
|
||||
- **需确认**:✅ 是否就这么做?确认后我会在 design 记一笔"决策8 反转"。
|
||||
- **影响**:对外契约——目标无权限时移动接口会返回权限错误(原本静默成功)。前端需对该错误给提示。
|
||||
|
||||
### #7 超大文件给提示 —— 推翻 AC-27
|
||||
- **现状**:AC-27 当初产品拍板"超大/格式不支持/隐藏文件一律**静默过滤、不报错、不占名额**"。`filterFolderUploadFiles` 现在返回 `File[]`,把"被过滤几个/什么原因"全丢了。
|
||||
- **本轮诉求**:超大文件要有提示(如"2 个文件因超出大小限制被跳过")。
|
||||
- **拟定方案**:`filterFolderUploadFiles` 返回结构从 `File[]` 改为 `{ valid: File[]; oversizeCount; ... }`(或带被剔除分类计数);`useFileUpload.handleUploadFolder` 在过滤后,`oversizeCount>0` 时 `showToast` 一条 WARNING,新增三语 i18n key。两个入口(picker + 拖拽)都覆盖(过滤只在这一个函数)。
|
||||
- **需确认**:✅ 提示范围 —— 我倾向「超大 + 不支持格式」都给汇总提示,隐藏文件仍静默。这样可以吗?
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计中(ultrathink)
|
||||
|
||||
### #6 弹窗左侧漏掉「用权限模板授权」的空间
|
||||
- **根因**:两套权限口径不一致。
|
||||
- 列表 `list_uploadable_spaces`:`list_objects(can_edit)` —— **粗粒度 OpenFGA 计算关系**,只认 relation 层级(editor/manager/owner ⊃ can_edit)。
|
||||
- 真实上传鉴权 `_get_effective_permission_ids`:**细粒度**,认每个 tuple 绑定的关系模型的 `permissions[]`,含 upload_file 即放行。
|
||||
- 不一致场景:管理员用「关系模板」(自定义权限模型,permissions[] 含 upload_file、但底层 relation 是 viewer 级,算不出 can_edit)授权 → 列表漏掉、实际能传。
|
||||
- **拟定方案 A**:候选 = `list_objects(can_read)` ∪ 用户创建的空间,再逐个 `_get_effective_permission_ids("knowledge_space", id)` 过滤 `"upload_file" in ids`,与真实上传同口径。
|
||||
- **⚠️ 方案 A 的关键前提(正在后台验证)**:
|
||||
1. **覆盖性**:`can_read` 候选是否一定是"所有被授予 upload_file 的空间"的**超集**?取决于「关系模板能绑定到哪些 OpenFGA relation」——若限于 {viewer,editor,manager,owner}(都被 can_read 覆盖)则安全;若能绑定到 can_read 不覆盖的 relation 则会漏,需改用更底层的列举 relation。**这是 ultrathink 的核心,不验证不能动手。**
|
||||
2. **性能**:can_read 候选可能比 can_edit 多(含只读空间);helper 有实例缓存,增量 = 每空间一次 membership/public 查询;N 较大(≤200)时评估是否要批量化。
|
||||
3. **共用影响**:`list_uploadable_spaces` 还被 F028(会话导出选目标)等调用,改进口径对它们也应是正确的,但要回归。
|
||||
- **替代**:若前提不成立 → 用模型里更底层、被所有档位蕴含的列举 relation;或候选并入"用户有任意 tuple 的空间"。
|
||||
- **下一步**:等后台验证结论 → 定稿方案 → 实现 + 回归 F028。
|
||||
|
||||
---
|
||||
|
||||
## 4. 待复现确认(代码看着是对的,先不盲改)
|
||||
|
||||
### #8 上传文件夹超 10 级,前端无 toast
|
||||
- **现状**:后端建树后超 10 级会 `raise SpaceFolderDepthError()`(18011),三语文案齐全,`uploadFolderApi` 走 `skip403Redirect` 统一拦截 → 翻译 → toast,链路理论完整。
|
||||
- **可疑点**:① 那次后端**是否真的命中** 18011(前端递归层级与后端 base_child_level 口径是否一致);② `window.showToast` 是否在该页挂载。
|
||||
- **要复现的证据**:超层那次 `POST .../folders/upload` 响应体里 `status_code` 是不是 18011?有没有进 toast?
|
||||
|
||||
### #9 上传文件夹超空间容量,没拦住
|
||||
- **现状**:`upload_folder_items` 的容量整批预校验代码正确(按 items.size 求和 vs 用户档 18024 / 租户 19403),前端也传了 size。
|
||||
- **最可能**:① 测试用了 **admin 账号**(admin 不受用户档容量限制,设计如此);② 测试角色/租户根本没配额度(默认 -1 = 无限)。
|
||||
- **要确认**:测试账号是不是 admin?有没有给角色/租户配限额?
|
||||
|
||||
---
|
||||
|
||||
## 5. 涉及文件清单(本轮)
|
||||
|
||||
**前端 client**:
|
||||
- `pages/knowledge/SpaceDetail/index.tsx` — #3 #4(displayFiles 过滤、canMoveFolder 探测、canBatchMove、下发双权限)
|
||||
- `pages/knowledge/SpaceDetail/FileTable.tsx` — #1 #4
|
||||
- `pages/knowledge/SpaceDetail/FileCard.tsx` — #1
|
||||
- (#7 待定)`pages/knowledge/knowledgeUtils.ts`、`hooks/useFileUpload.ts`、locale ×3
|
||||
- (#8 复现后可能)`hooks/useFileUpload.ts` / `useToast` 挂载
|
||||
|
||||
**后端**:
|
||||
- `knowledge/domain/services/knowledge_space_service.py` — #2(已改)、#5(待定 move_items 目标校验)、#6(待定 list_uploadable_spaces)
|
||||
- `test/knowledge/test_knowledge_space_move.py` — #2 补测
|
||||
- (#9 复现后)`upload_folder_items` 容量校验 / 配额配置
|
||||
|
||||
---
|
||||
|
||||
## 6. 执行计划(一步一步)
|
||||
|
||||
1. ✅ 调查定位全部 9+1(4 路子智能体已完成 + #2/#6 各补 1 路)。
|
||||
2. ✅ 已改 #1 #2(代码)#3 #4。
|
||||
3. ⏳ **本步**:落档(本文件)+ 等用户对 #5/#7 拍板 + 等 #6 前提验证回来。
|
||||
4. ⬜ 补 #2 单测,跑后端 `test_knowledge_space_move*`。
|
||||
5. ⬜ #5 实现(确认后):move_items 加目标 upload_file 校验 + 前端错误提示 + 单测。
|
||||
6. ⬜ #6 定稿(前提验证后):改 list_uploadable_spaces + 回归 F028。
|
||||
7. ⬜ #7 实现(确认后):filterFolderUploadFiles 返回结构 + toast + i18n。
|
||||
8. ⬜ #8 #9 复现:拿到证据后定夺。
|
||||
9. ⬜ 前端类型检查 + 后端测试整体回归;design/tasks 回写偏差。
|
||||
|
||||
---
|
||||
|
||||
## 修订历史
|
||||
|
||||
| 日期 | 改动 |
|
||||
|---|---|
|
||||
| 2026-06-16 | 建档:beta4 测试反馈 9+1 集中记录;#1/#2/#3/#4 已改代码;#5/#7 待拍板;#6 方案前提后台验证中;#8/#9 待复现。 |
|
||||
@@ -1958,7 +1958,7 @@ class KnowledgeSpaceService(KnowledgeUtils):
|
||||
"""
|
||||
accessible_ids = await PermissionService.list_accessible_ids(
|
||||
user_id=self.login_user.user_id,
|
||||
relation="can_edit",
|
||||
relation="can_read",
|
||||
object_type="knowledge_space",
|
||||
login_user=self.login_user,
|
||||
)
|
||||
@@ -1984,6 +1984,17 @@ class KnowledgeSpaceService(KnowledgeUtils):
|
||||
return []
|
||||
spaces = await KnowledgeDao.aget_list_by_ids(list(ids))
|
||||
spaces = [s for s in spaces if s.type == KnowledgeTypeEnum.SPACE.value]
|
||||
# can_read only narrows the candidate set; filter to spaces the user
|
||||
# can actually upload into, using the SAME fine-grained check as the
|
||||
# upload path. A custom permission template may grant upload_file
|
||||
# under a viewer-tier relation that can_read/can_edit can't express,
|
||||
# so the coarse list_objects relation alone is not a valid proxy.
|
||||
uploadable = []
|
||||
for s in spaces:
|
||||
perms = await self._get_effective_permission_ids("knowledge_space", s.id)
|
||||
if "upload_file" in perms:
|
||||
uploadable.append(s)
|
||||
spaces = uploadable
|
||||
spaces.sort(
|
||||
key=lambda s: s.update_time or datetime.min,
|
||||
reverse=True,
|
||||
@@ -3580,6 +3591,23 @@ class KnowledgeSpaceService(KnowledgeUtils):
|
||||
target_level = 0
|
||||
new_parent = ("knowledge_space", target_space_id)
|
||||
|
||||
# Target-side upload permission (same + cross space): moving items
|
||||
# into a folder / space root is semantically "placing files there",
|
||||
# so require upload_file on the target — mirrors add_file. The move
|
||||
# dialog already hides targets the user can't upload to; this also
|
||||
# blocks direct API calls into a no-permission target (decision-8
|
||||
# reversal, see 测试反馈修复-R1.md #5).
|
||||
if target_folder_id:
|
||||
target_perms = await self._get_effective_permission_ids(
|
||||
"folder", target_folder_id, space_id=target_space_id
|
||||
)
|
||||
else:
|
||||
target_perms = await self._get_effective_permission_ids(
|
||||
"knowledge_space", target_space_id, space_id=target_space_id
|
||||
)
|
||||
if "upload_file" not in target_perms:
|
||||
raise SpacePermissionDeniedError()
|
||||
|
||||
# ── validate each item ──
|
||||
valid: list[KnowledgeFile] = []
|
||||
invalid: list[dict] = []
|
||||
@@ -3627,12 +3655,21 @@ class KnowledgeSpaceService(KnowledgeUtils):
|
||||
# same-space and cross-space moves (the dup query already scopes
|
||||
# to target_space_id). Files are never name-checked on move.
|
||||
if reason is None and is_folder:
|
||||
# Root-level rows persist file_level_path as NULL or "";
|
||||
# normalise both at the target root, otherwise SQL's
|
||||
# NULL == "" (false) lets a same-name root folder slip
|
||||
# through the dup check and the move silently succeeds.
|
||||
level_path_cond = (
|
||||
SpaceFileDao._root_path_filter()
|
||||
if target_level_path == ""
|
||||
else KnowledgeFile.file_level_path == target_level_path
|
||||
)
|
||||
dup = await session.scalar(
|
||||
select(func.count(KnowledgeFile.id)).where(
|
||||
KnowledgeFile.knowledge_id == target_space_id,
|
||||
KnowledgeFile.file_type == FileType.DIR.value,
|
||||
KnowledgeFile.file_name == rec.file_name,
|
||||
KnowledgeFile.file_level_path == target_level_path,
|
||||
level_path_cond,
|
||||
KnowledgeFile.id != rec.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from bisheng.common.errcode.knowledge_space import (
|
||||
SpacePermissionDeniedError,
|
||||
SpaceTenantMismatchError,
|
||||
)
|
||||
from bisheng.knowledge.domain.models.knowledge import Knowledge
|
||||
@@ -286,7 +287,9 @@ async def test_no_permission_blocks_item(async_db_session):
|
||||
await _add(async_db_session, id=20, knowledge_id=1, file_name="a.pdf", file_type=FILE, level=0, file_level_path="")
|
||||
|
||||
svc = _svc()
|
||||
svc._get_effective_permission_ids = AsyncMock(return_value={"view_space", "view_file"}) # no move_file
|
||||
# upload_file present so the target check (#5) passes; no move_file on the
|
||||
# item → it's rejected with no_permission by the per-item gate.
|
||||
svc._get_effective_permission_ids = AsyncMock(return_value={"upload_file", "view_space", "view_file"})
|
||||
res = await svc.move_items(1, [{"id": 20, "type": "file"}], target_space_id=1, target_folder_id=10)
|
||||
assert res["moved"] == []
|
||||
assert res["invalid"][0]["reason"] == "no_permission"
|
||||
@@ -387,3 +390,33 @@ async def test_cross_space_cross_tenant_rejected(async_db_session):
|
||||
svc = _svc()
|
||||
with pytest.raises(SpaceTenantMismatchError):
|
||||
await svc.move_items(1, [{"id": 20, "type": "file"}], target_space_id=9, target_folder_id=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_space_folder_name_conflict_at_target_root_null_path(async_db_session):
|
||||
"""#2 fix: a same-name folder at the TARGET space root must be detected even
|
||||
when its file_level_path is NULL (historical rows), not "". Without root-path
|
||||
normalisation NULL == "" is false in SQL and the dup check silently passes."""
|
||||
await _seed_spaces(async_db_session)
|
||||
await _add(async_db_session, id=210, knowledge_id=2, file_name="dup", file_type=DIR, level=0, file_level_path=None)
|
||||
await _add(async_db_session, id=211, knowledge_id=1, file_name="dup", file_type=DIR, level=0, file_level_path="")
|
||||
|
||||
svc = _svc()
|
||||
res = await svc.move_items(1, [{"id": 211, "type": "folder"}], target_space_id=2, target_folder_id=None)
|
||||
assert res["invalid"][0]["reason"] == "name_conflict"
|
||||
assert res["moved"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_rejected_without_upload_file_on_target(async_db_session):
|
||||
"""#5 (decision-8 reversal): moving into a target the user has no upload_file
|
||||
on is rejected server-side. The target check runs before per-item checks, so
|
||||
a missing upload_file raises outright."""
|
||||
await _seed_spaces(async_db_session)
|
||||
await _add(async_db_session, id=10, knowledge_id=1, file_name="dst", file_type=DIR, level=0, file_level_path="")
|
||||
await _add(async_db_session, id=20, knowledge_id=1, file_name="a.pdf", file_type=FILE, level=0, file_level_path="")
|
||||
|
||||
svc = _svc()
|
||||
svc._get_effective_permission_ids = AsyncMock(return_value={"move_file", "move_folder", "view_space"})
|
||||
with pytest.raises(SpacePermissionDeniedError):
|
||||
await svc.move_items(1, [{"id": 20, "type": "file"}], target_space_id=1, target_folder_id=10)
|
||||
|
||||
@@ -15,7 +15,6 @@ AC coverage: AC-17
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +30,6 @@ from bisheng.knowledge.domain.services.knowledge_space_service import (
|
||||
from bisheng.permission.domain.services.permission_service import PermissionService
|
||||
from bisheng.user.domain.services.auth import LoginUser
|
||||
|
||||
|
||||
# --- Fixtures --------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -41,8 +39,8 @@ def _make_knowledge(
|
||||
*,
|
||||
user_id: int = 1,
|
||||
type_: int = KnowledgeTypeEnum.SPACE.value,
|
||||
description: Optional[str] = None,
|
||||
update_time: Optional[datetime] = None,
|
||||
description: str | None = None,
|
||||
update_time: datetime | None = None,
|
||||
) -> Knowledge:
|
||||
return Knowledge(
|
||||
id=id_,
|
||||
@@ -64,7 +62,10 @@ def service() -> KnowledgeSpaceService:
|
||||
"""
|
||||
request = MagicMock()
|
||||
login_user = LoginUser(
|
||||
user_id=1, user_name='Admin', user_role=[], tenant_id=1,
|
||||
user_id=1,
|
||||
user_name="Admin",
|
||||
user_role=[],
|
||||
tenant_id=1,
|
||||
)
|
||||
return KnowledgeSpaceService(request=request, login_user=login_user)
|
||||
|
||||
@@ -74,41 +75,68 @@ def patch_perm_and_dao(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Mock PermissionService.list_accessible_ids and KnowledgeDao methods."""
|
||||
|
||||
state: dict = {
|
||||
'accessible_ids': [], # None → admin path; list → normal user path
|
||||
'created_ids': [], # creator-owned space ids
|
||||
'spaces_by_ids': {}, # id → Knowledge
|
||||
"accessible_ids": [], # None → admin path; list → normal user path
|
||||
"created_ids": [], # creator-owned space ids
|
||||
"spaces_by_ids": {}, # id → Knowledge
|
||||
"uploadable_ids": None, # None → every candidate has upload_file;
|
||||
# set → only these ids do (others filtered)
|
||||
}
|
||||
|
||||
async def _fake_list_accessible_ids(cls, *, user_id, relation, object_type, login_user=None):
|
||||
return state['accessible_ids']
|
||||
return state["accessible_ids"]
|
||||
|
||||
async def _fake_effective_perms(self, object_type, object_id, *, space_id=None):
|
||||
# ⑥: list_uploadable_spaces now filters candidates by the fine-grained
|
||||
# upload_file permission. Default: grant it to all (filter is a no-op so
|
||||
# the union/type/keyword/sort assertions below still hold); per-id
|
||||
# control via set_uploadable_ids() exercises the actual filtering.
|
||||
if state["uploadable_ids"] is None or int(object_id) in state["uploadable_ids"]:
|
||||
return {"upload_file", "view_space"}
|
||||
return {"view_space"}
|
||||
|
||||
async def _fake_get_created_ids(cls, user_id, knowledge_type):
|
||||
return list(state['created_ids'])
|
||||
return list(state["created_ids"])
|
||||
|
||||
async def _fake_get_list_by_ids(cls, ids):
|
||||
return [state['spaces_by_ids'][i] for i in ids if i in state['spaces_by_ids']]
|
||||
return [state["spaces_by_ids"][i] for i in ids if i in state["spaces_by_ids"]]
|
||||
|
||||
monkeypatch.setattr(
|
||||
PermissionService, 'list_accessible_ids',
|
||||
PermissionService,
|
||||
"list_accessible_ids",
|
||||
classmethod(_fake_list_accessible_ids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
KnowledgeDao, 'aget_knowledge_ids_created_by',
|
||||
KnowledgeDao,
|
||||
"aget_knowledge_ids_created_by",
|
||||
classmethod(_fake_get_created_ids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
KnowledgeDao, 'aget_list_by_ids',
|
||||
KnowledgeDao,
|
||||
"aget_list_by_ids",
|
||||
classmethod(_fake_get_list_by_ids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
KnowledgeSpaceService,
|
||||
"_get_effective_permission_ids",
|
||||
_fake_effective_perms,
|
||||
)
|
||||
|
||||
class _Registry:
|
||||
@staticmethod
|
||||
def set_accessible_ids(ids): state['accessible_ids'] = ids
|
||||
def set_accessible_ids(ids):
|
||||
state["accessible_ids"] = ids
|
||||
|
||||
@staticmethod
|
||||
def set_created_ids(ids): state['created_ids'] = list(ids)
|
||||
def set_created_ids(ids):
|
||||
state["created_ids"] = list(ids)
|
||||
|
||||
@staticmethod
|
||||
def set_spaces(spaces):
|
||||
state['spaces_by_ids'] = {s.id: s for s in spaces}
|
||||
state["spaces_by_ids"] = {s.id: s for s in spaces}
|
||||
|
||||
@staticmethod
|
||||
def set_uploadable_ids(ids):
|
||||
state["uploadable_ids"] = set(ids)
|
||||
|
||||
return _Registry()
|
||||
|
||||
@@ -118,12 +146,14 @@ def patch_perm_and_dao(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
async def test_list_uploadable_filters_by_can_edit(service, patch_perm_and_dao):
|
||||
"""AC-17: 普通用户取 OpenFGA can_edit 列表, 拉对应 Knowledge 元信息。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['42', '56'])
|
||||
patch_perm_and_dao.set_accessible_ids(["42", "56"])
|
||||
patch_perm_and_dao.set_created_ids([])
|
||||
patch_perm_and_dao.set_spaces([
|
||||
_make_knowledge(42, '宏观研究'),
|
||||
_make_knowledge(56, '黄金专题'),
|
||||
])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(42, "宏观研究"),
|
||||
_make_knowledge(56, "黄金专题"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
ids = sorted(s.id for s in result)
|
||||
@@ -132,12 +162,14 @@ async def test_list_uploadable_filters_by_can_edit(service, patch_perm_and_dao):
|
||||
|
||||
async def test_list_uploadable_unions_creator_owned(service, patch_perm_and_dao):
|
||||
"""FGA list + creator-owned spaces 取并集。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['42'])
|
||||
patch_perm_and_dao.set_accessible_ids(["42"])
|
||||
patch_perm_and_dao.set_created_ids([99])
|
||||
patch_perm_and_dao.set_spaces([
|
||||
_make_knowledge(42, '宏观研究'),
|
||||
_make_knowledge(99, '我自己创建的'),
|
||||
])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(42, "宏观研究"),
|
||||
_make_knowledge(99, "我自己创建的"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
ids = sorted(s.id for s in result)
|
||||
@@ -147,12 +179,14 @@ async def test_list_uploadable_unions_creator_owned(service, patch_perm_and_dao)
|
||||
async def test_list_uploadable_sorts_by_update_time_desc(service, patch_perm_and_dao):
|
||||
"""按 update_time desc 排序。"""
|
||||
now = datetime(2026, 5, 31, 0, 0, 0)
|
||||
patch_perm_and_dao.set_accessible_ids(['1', '2', '3'])
|
||||
patch_perm_and_dao.set_spaces([
|
||||
_make_knowledge(1, 'oldest', update_time=now - timedelta(days=10)),
|
||||
_make_knowledge(2, 'newest', update_time=now),
|
||||
_make_knowledge(3, 'middle', update_time=now - timedelta(days=5)),
|
||||
])
|
||||
patch_perm_and_dao.set_accessible_ids(["1", "2", "3"])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(1, "oldest", update_time=now - timedelta(days=10)),
|
||||
_make_knowledge(2, "newest", update_time=now),
|
||||
_make_knowledge(3, "middle", update_time=now - timedelta(days=5)),
|
||||
]
|
||||
)
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
assert [s.id for s in result] == [2, 3, 1]
|
||||
@@ -160,11 +194,30 @@ async def test_list_uploadable_sorts_by_update_time_desc(service, patch_perm_and
|
||||
|
||||
async def test_list_uploadable_excludes_non_space_type(service, patch_perm_and_dao):
|
||||
"""KnowledgeDao 返了非 SPACE 类型的资源 → 必须过滤掉。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['1', '2'])
|
||||
patch_perm_and_dao.set_spaces([
|
||||
_make_knowledge(1, 'space', type_=KnowledgeTypeEnum.SPACE.value),
|
||||
_make_knowledge(2, 'normal-kb', type_=KnowledgeTypeEnum.NORMAL.value),
|
||||
])
|
||||
patch_perm_and_dao.set_accessible_ids(["1", "2"])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(1, "space", type_=KnowledgeTypeEnum.SPACE.value),
|
||||
_make_knowledge(2, "normal-kb", type_=KnowledgeTypeEnum.NORMAL.value),
|
||||
]
|
||||
)
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
assert [s.id for s in result] == [1]
|
||||
|
||||
|
||||
async def test_list_uploadable_excludes_no_upload_permission(service, patch_perm_and_dao):
|
||||
"""⑥: a candidate the user can READ but has NO upload_file on is excluded.
|
||||
can_read surfaces it as a candidate; the fine-grained filter drops it — the
|
||||
custom-template-grants-upload-under-viewer case the coarse relation missed."""
|
||||
patch_perm_and_dao.set_accessible_ids(["1", "2"])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(1, "can-upload"),
|
||||
_make_knowledge(2, "read-only"),
|
||||
]
|
||||
)
|
||||
patch_perm_and_dao.set_uploadable_ids([1]) # only space 1 grants upload_file
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
assert [s.id for s in result] == [1]
|
||||
@@ -185,9 +238,9 @@ async def test_list_uploadable_empty(service, patch_perm_and_dao):
|
||||
|
||||
async def test_list_uploadable_non_numeric_ids_skipped(service, patch_perm_and_dao):
|
||||
"""FGA 返回的 id 非数字 → 跳过, 不抛 (防御性)。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['not-a-number', '42'])
|
||||
patch_perm_and_dao.set_accessible_ids(["not-a-number", "42"])
|
||||
patch_perm_and_dao.set_created_ids([])
|
||||
patch_perm_and_dao.set_spaces([_make_knowledge(42, '宏观研究')])
|
||||
patch_perm_and_dao.set_spaces([_make_knowledge(42, "宏观研究")])
|
||||
|
||||
result = await service.list_uploadable_spaces()
|
||||
assert [s.id for s in result] == [42]
|
||||
@@ -198,24 +251,26 @@ async def test_list_uploadable_non_numeric_ids_skipped(service, patch_perm_and_d
|
||||
|
||||
async def test_list_uploadable_keyword_filter(service, patch_perm_and_dao):
|
||||
"""AC-17: 关键词子串匹配 (大小写不敏感)。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['1', '2', '3'])
|
||||
patch_perm_and_dao.set_spaces([
|
||||
_make_knowledge(1, '黄金行情'),
|
||||
_make_knowledge(2, '宏观研究'),
|
||||
_make_knowledge(3, '股票黄金 ETF'),
|
||||
])
|
||||
patch_perm_and_dao.set_accessible_ids(["1", "2", "3"])
|
||||
patch_perm_and_dao.set_spaces(
|
||||
[
|
||||
_make_knowledge(1, "黄金行情"),
|
||||
_make_knowledge(2, "宏观研究"),
|
||||
_make_knowledge(3, "股票黄金 ETF"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await service.list_uploadable_spaces(keyword='黄金')
|
||||
result = await service.list_uploadable_spaces(keyword="黄金")
|
||||
ids = sorted(s.id for s in result)
|
||||
assert ids == [1, 3]
|
||||
|
||||
|
||||
async def test_list_uploadable_keyword_case_insensitive(service, patch_perm_and_dao):
|
||||
"""关键词大小写不敏感对英文 name 也生效。"""
|
||||
patch_perm_and_dao.set_accessible_ids(['1'])
|
||||
patch_perm_and_dao.set_spaces([_make_knowledge(1, 'Macro Research')])
|
||||
patch_perm_and_dao.set_accessible_ids(["1"])
|
||||
patch_perm_and_dao.set_spaces([_make_knowledge(1, "Macro Research")])
|
||||
|
||||
result = await service.list_uploadable_spaces(keyword='MACRO')
|
||||
result = await service.list_uploadable_spaces(keyword="MACRO")
|
||||
assert [s.id for s in result] == [1]
|
||||
|
||||
|
||||
@@ -229,23 +284,30 @@ async def test_list_uploadable_admin_sees_all_spaces(service, patch_perm_and_dao
|
||||
# The admin branch falls through to a raw select() with session.exec().
|
||||
# Stub the session context manager + exec to return a curated list.
|
||||
admin_spaces = [
|
||||
_make_knowledge(7, 'tenant-wide-1'),
|
||||
_make_knowledge(8, 'tenant-wide-2'),
|
||||
_make_knowledge(7, "tenant-wide-1"),
|
||||
_make_knowledge(8, "tenant-wide-2"),
|
||||
]
|
||||
|
||||
class _StubResult:
|
||||
def __init__(self, rows): self._rows = rows
|
||||
def all(self): return self._rows
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
class _StubSession:
|
||||
async def exec(self, _stmt): return _StubResult(admin_spaces)
|
||||
async def exec(self, _stmt):
|
||||
return _StubResult(admin_spaces)
|
||||
|
||||
class _CMSession:
|
||||
async def __aenter__(self): return _StubSession()
|
||||
async def __aexit__(self, *a): return False
|
||||
async def __aenter__(self):
|
||||
return _StubSession()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
'bisheng.knowledge.domain.services.knowledge_space_service.get_async_db_session',
|
||||
"bisheng.knowledge.domain.services.knowledge_space_service.get_async_db_session",
|
||||
lambda: _CMSession(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1551,6 +1551,8 @@
|
||||
"folder_name_empty": "Folder name cannot be empty",
|
||||
"folder_upload_exceed_limit": "A folder upload can contain at most {{0}} files.",
|
||||
"folder_upload_no_valid_files": "No uploadable files in the folder (unsupported, hidden, and oversize files are filtered out).",
|
||||
"folder_upload_skipped_oversize": "{{0}} file(s) skipped for exceeding the size limit",
|
||||
"folder_upload_skipped_unsupported": "{{0}} file(s) skipped for unsupported format",
|
||||
"format_list": "pdf (including scans), ofd, txt, docx, ppt, pptx, md, html, xls, xlsx, csv, doc, png, jpg, jpeg, bmp, wps, dps, et",
|
||||
"get_download_link_failed": "Failed to get download link",
|
||||
"go_to_square": "Go to square",
|
||||
|
||||
@@ -1475,6 +1475,8 @@
|
||||
"folder_name_empty": "フォルダー名を入力してください",
|
||||
"folder_upload_exceed_limit": "1 回のフォルダーアップロードで最大 {{0}} 件までです。",
|
||||
"folder_upload_no_valid_files": "フォルダー内にアップロード可能なファイルがありません(未対応形式・隠しファイル・サイズ超過のファイルは除外されます)",
|
||||
"folder_upload_skipped_oversize": "{{0}} 件のファイルはサイズ上限超過のためスキップされました",
|
||||
"folder_upload_skipped_unsupported": "{{0}} 件のファイルは未対応形式のためスキップされました",
|
||||
"format_list": "pdf(スキャンを含む)、ofd、txt、docx、ppt、pptx、md、html、xls、xlsx、csv、doc、png、jpg、jpeg、bmp、wps、dps、et",
|
||||
"get_download_link_failed": "ダウンロードリンクの取得に失敗しました",
|
||||
"go_to_square": "広場へ行く",
|
||||
|
||||
@@ -1478,6 +1478,8 @@
|
||||
"folder_name_empty": "文件夹名称不能为空",
|
||||
"folder_upload_exceed_limit": "单次批量文件总数最多 {{0}} 个",
|
||||
"folder_upload_no_valid_files": "文件夹内没有可上传的文件(不支持格式、隐藏文件及超大文件已被过滤)",
|
||||
"folder_upload_skipped_oversize": "{{0}} 个文件因超出大小限制已跳过",
|
||||
"folder_upload_skipped_unsupported": "{{0}} 个文件因格式不支持已跳过",
|
||||
"format_list": "pdf(含扫描件)、ofd、txt、docx、ppt、pptx、md、html、xls、xlsx、csv、doc、png、jpg、jpeg、bmp、wps、dps、et",
|
||||
"get_download_link_failed": "下载链接获取失败",
|
||||
"go_to_square": "前往广场",
|
||||
|
||||
@@ -584,7 +584,7 @@ export function FileCard({
|
||||
)}
|
||||
{showMoveItem && (
|
||||
<ActionMenuItem
|
||||
disabled={!canMove}
|
||||
disabled={!canMove || isUploading}
|
||||
onClick={(e) => { e.stopPropagation(); onMove?.(); }}
|
||||
icon={<FolderInput />}
|
||||
label={localize("com_knowledge.move")}
|
||||
@@ -728,7 +728,7 @@ export function FileCard({
|
||||
)}
|
||||
{showMoveItem && (
|
||||
<ActionMenuItem
|
||||
disabled={!canMove}
|
||||
disabled={!canMove || isUploading}
|
||||
onClick={(e) => { e.stopPropagation(); onMove?.(); }}
|
||||
icon={<FolderInput />}
|
||||
label={localize("com_knowledge.move")}
|
||||
|
||||
@@ -551,8 +551,10 @@ interface FileTableProps {
|
||||
onManagePermission?: (id: string) => void;
|
||||
/** F034: open the move dialog for a file/folder. Shown when provided. */
|
||||
onMove?: (file: KnowledgeFile) => void;
|
||||
/** F034: whether a file/folder can be moved (move permission in this space). */
|
||||
canMove?: boolean;
|
||||
/** F034: move permission for files / folders (move_file / move_folder). A
|
||||
* role may grant one without the other, so they're probed separately. */
|
||||
canMoveFile?: boolean;
|
||||
canMoveFolder?: boolean;
|
||||
/** F034 drag-move: drop dragged items into a same-space folder. */
|
||||
onMoveToFolder?: (folderId: string, items: KnowledgeFile[], folderName: string) => void;
|
||||
/** Version management gating for per-row version actions / badges. */
|
||||
@@ -576,7 +578,7 @@ interface FileTableProps {
|
||||
bottomSpacing?: number;
|
||||
}
|
||||
|
||||
export function FileTable({ files, selectedFiles, handleSelectAll, handleSelectFile, isAdmin, currentUserRole, onDownload, onEditTags, onRename, onDelete, onRetry, onNavigateFolder, onPreview, onValidateName, onCancelCreate, permissionEntryIds, renameEntryIds, deleteEntryIds, downloadEntryIds, onManagePermission, onMove, canMove = false, onMoveToFolder, versionManagementEnabled = false, onOpenVersionManagement, onOpenVersionHistory, canManageMembers = false, sortBy, sortDirection, onSort, highlightedTagIds, highlightKeyword, onScroll, bottomSpacing = 0 }: FileTableProps) {
|
||||
export function FileTable({ files, selectedFiles, handleSelectAll, handleSelectFile, isAdmin, currentUserRole, onDownload, onEditTags, onRename, onDelete, onRetry, onNavigateFolder, onPreview, onValidateName, onCancelCreate, permissionEntryIds, renameEntryIds, deleteEntryIds, downloadEntryIds, onManagePermission, onMove, canMoveFile = false, canMoveFolder = false, onMoveToFolder, versionManagementEnabled = false, onOpenVersionManagement, onOpenVersionHistory, canManageMembers = false, sortBy, sortDirection, onSort, highlightedTagIds, highlightKeyword, onScroll, bottomSpacing = 0 }: FileTableProps) {
|
||||
const { columnWidths, onResizeStart, totalWidth } = useResizableColumns();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hScrollRevealRef = useScrollRevealRef<HTMLDivElement>();
|
||||
@@ -698,7 +700,7 @@ export function FileTable({ files, selectedFiles, handleSelectAll, handleSelectF
|
||||
: undefined
|
||||
}
|
||||
onMove={onMove ? () => onMove(file) : undefined}
|
||||
canMove={canMove}
|
||||
canMove={file.type === FileType.FOLDER ? canMoveFolder : canMoveFile}
|
||||
versionManagementEnabled={versionManagementEnabled}
|
||||
onOpenVersionManagement={onOpenVersionManagement}
|
||||
onOpenVersionHistory={onOpenVersionHistory}
|
||||
@@ -914,7 +916,7 @@ function FileRow({
|
||||
)}
|
||||
{showMoveItem && (
|
||||
<ActionMenuItem
|
||||
disabled={!canMove}
|
||||
disabled={!canMove || isUploading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove?.();
|
||||
|
||||
@@ -138,7 +138,10 @@ export function KnowledgeSpaceContent({
|
||||
const tableScrollRevealRef = useScrollRevealRef<HTMLDivElement>();
|
||||
const displayFiles = [
|
||||
...(creatingFolder ? [creatingFolder] : []),
|
||||
...uploadingFiles,
|
||||
// Uploading placeholders are keyed to the space they were started in.
|
||||
// Filter to the active space so an in-progress upload in space A does
|
||||
// not leak into space B's list after switching spaces.
|
||||
...uploadingFiles.filter((f) => String(f.spaceId) === String(space.id)),
|
||||
...files
|
||||
];
|
||||
|
||||
@@ -306,8 +309,11 @@ export function KnowledgeSpaceContent({
|
||||
const [canCreateFolder, setCanCreateFolder] = useState(false);
|
||||
const [canUploadFile, setCanUploadFile] = useState(false);
|
||||
// Move permission is separate from upload (both can_edit tier, but a role may
|
||||
// grant one without the other). Drives the move menu items' greyed state.
|
||||
// grant one without the other). Files and folders have independent move
|
||||
// permissions (move_file / move_folder) — probe both so a user with only
|
||||
// one of them isn't blocked on the other. Drives the move menu greyed state.
|
||||
const [canMoveFile, setCanMoveFile] = useState(false);
|
||||
const [canMoveFolder, setCanMoveFolder] = useState(false);
|
||||
const isSearching = searchQuery.trim().length > 0 || searchTagIds.length > 0;
|
||||
const [permTarget, setPermTarget] = useState<{
|
||||
id: string;
|
||||
@@ -356,7 +362,14 @@ export function KnowledgeSpaceContent({
|
||||
"move_file",
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
]).then(([createFolderResult, uploadFileResult, moveFileResult]) => {
|
||||
checkPermission(
|
||||
objectType,
|
||||
objectId,
|
||||
"can_edit",
|
||||
"move_folder",
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
]).then(([createFolderResult, uploadFileResult, moveFileResult, moveFolderResult]) => {
|
||||
if (cancelled) return;
|
||||
setCanCreateFolder(
|
||||
createFolderResult.status === "fulfilled" && Boolean(createFolderResult.value?.allowed)
|
||||
@@ -367,11 +380,15 @@ export function KnowledgeSpaceContent({
|
||||
setCanMoveFile(
|
||||
moveFileResult.status === "fulfilled" && Boolean(moveFileResult.value?.allowed)
|
||||
);
|
||||
setCanMoveFolder(
|
||||
moveFolderResult.status === "fulfilled" && Boolean(moveFolderResult.value?.allowed)
|
||||
);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCanCreateFolder(false);
|
||||
setCanUploadFile(false);
|
||||
setCanMoveFile(false);
|
||||
setCanMoveFolder(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -940,6 +957,15 @@ export function KnowledgeSpaceContent({
|
||||
// Uploading placeholders have no backend identity yet — a selection containing one
|
||||
// cannot be moved (the menu entry is disabled below).
|
||||
const selectionHasUploading = selectedList.some((f) => isKnowledgeItemUploading(f));
|
||||
const selectionHasFile = selectedList.some((f) => f.type !== FileType.FOLDER);
|
||||
// Batch move requires the matching move permission for every kind in the
|
||||
// selection: folders need move_folder, files need move_file (a role may
|
||||
// grant only one). Uploading placeholders also block it.
|
||||
const canBatchMove =
|
||||
selectedList.length > 0 &&
|
||||
!selectionHasUploading &&
|
||||
(!hasFoldersSelected || canMoveFolder) &&
|
||||
(!selectionHasFile || canMoveFile);
|
||||
const canBatchDelete = selectedList.length > 0 && selectedList.every((file) =>
|
||||
deleteEntryIds.has(file.id)
|
||||
);
|
||||
@@ -977,7 +1003,7 @@ export function KnowledgeSpaceContent({
|
||||
},
|
||||
(isAdmin && !hasFoldersSelected) && { key: "tag", label: localize("com_knowledge.batch_add_tags"), Icon: Outlined.Tag, onClick: handleBatchTag },
|
||||
(isAdmin && hasFailedFiles) && { key: "retry", label: localize("com_knowledge.retry"), Icon: Outlined.Refresh, onClick: handleBatchRetry },
|
||||
(canMoveFile && !selectionHasUploading) && { key: "move", label: localize("com_knowledge.move"), Icon: FolderInput, onClick: handleBatchMove },
|
||||
canBatchMove && { key: "move", label: localize("com_knowledge.move"), Icon: FolderInput, onClick: handleBatchMove },
|
||||
canManageSinglePermission && { key: "permission", label: localize("com_permission.manage_permission"), Icon: Outlined.PeopleSafe, onClick: () => handleManagePermission(singleSelectedId!) },
|
||||
canBatchDelete && { key: "delete", label: localize("com_knowledge.delete"), Icon: Outlined.Delete, onClick: handleBatchDelete, danger: true },
|
||||
].filter(Boolean) as BatchAction[];
|
||||
@@ -1197,7 +1223,7 @@ export function KnowledgeSpaceContent({
|
||||
onBatchTag={handleBatchTag}
|
||||
onBatchRetry={handleBatchRetry}
|
||||
onBatchMove={handleBatchMove}
|
||||
canBatchMove={canMoveFile && !selectionHasUploading}
|
||||
canBatchMove={canBatchMove}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
canBatchDelete={canBatchDelete}
|
||||
onGoKnowledgeSquare={onGoKnowledgeSquare}
|
||||
@@ -1292,7 +1318,7 @@ export function KnowledgeSpaceContent({
|
||||
onDownload={() => handleSingleDownload(file.id)}
|
||||
onRename={(newName) => onRenameFile(file.id, newName)}
|
||||
onMove={() => openMove([file])}
|
||||
canMove={canMoveFile}
|
||||
canMove={file.type === FileType.FOLDER ? canMoveFolder : canMoveFile}
|
||||
onDelete={() => handleDelete(file.id)}
|
||||
onEditTags={() => handleOpenEditTags(file.id)}
|
||||
onRetry={() => handleSingleRetry(file.id)}
|
||||
@@ -1339,7 +1365,8 @@ export function KnowledgeSpaceContent({
|
||||
onEditTags={(id) => handleOpenEditTags(id)}
|
||||
onRename={(id, newName) => onRenameFile(id, newName)}
|
||||
onMove={(file) => openMove([file])}
|
||||
canMove={canMoveFile}
|
||||
canMoveFile={canMoveFile}
|
||||
canMoveFolder={canMoveFolder}
|
||||
onMoveToFolder={canUploadFile ? (folderId, items, folderName) => dropMoveToFolder(items, folderId, folderName) : undefined}
|
||||
onDelete={(id) => handleDelete(id)}
|
||||
onRetry={(id) => handleSingleRetry(id)}
|
||||
|
||||
@@ -340,7 +340,21 @@ export function useFileUpload({
|
||||
// via createFolderApi if it really collides.
|
||||
}
|
||||
|
||||
const validFiles = filterFolderUploadFiles(allFiles, options);
|
||||
const { valid: validFiles, oversizeCount, unsupportedCount } =
|
||||
filterFolderUploadFiles(allFiles, options);
|
||||
// ⑦: tell the user which files were dropped (oversize / unsupported
|
||||
// format) instead of silently skipping them; hidden files stay
|
||||
// silent. Shown even when some valid files still upload.
|
||||
if (oversizeCount > 0 || unsupportedCount > 0) {
|
||||
const parts: string[] = [];
|
||||
if (oversizeCount > 0) {
|
||||
parts.push(localize("com_knowledge.folder_upload_skipped_oversize", { 0: oversizeCount }));
|
||||
}
|
||||
if (unsupportedCount > 0) {
|
||||
parts.push(localize("com_knowledge.folder_upload_skipped_unsupported", { 0: unsupportedCount }));
|
||||
}
|
||||
showToast({ message: parts.join("\n"), severity: NotificationSeverity.WARNING });
|
||||
}
|
||||
if (validFiles.length === 0) {
|
||||
// Every file was silently filtered (format / hidden / oversize):
|
||||
// nothing to upload, and no empty tree is created (AC-27 edge).
|
||||
|
||||
@@ -237,20 +237,40 @@ export function isHiddenPath(relativePath: string): boolean {
|
||||
*
|
||||
* Caller is expected to have already handled the integral-batch rejections
|
||||
* (hidden root folder / duplicate folder name / > MAX_FOLDER_UPLOAD_COUNT).
|
||||
*
|
||||
* Returns the kept files plus counts of what was dropped, so the caller can
|
||||
* tell the user why some files didn't upload (oversize / unsupported). Hidden
|
||||
* files stay a silent drop (expected behaviour, not worth a toast).
|
||||
*/
|
||||
export interface FolderUploadFilterResult {
|
||||
valid: File[];
|
||||
oversizeCount: number;
|
||||
unsupportedCount: number;
|
||||
}
|
||||
|
||||
export function filterFolderUploadFiles(
|
||||
files: File[],
|
||||
options: { allowedExtensions: readonly string[]; maxSizeMB: number },
|
||||
): File[] {
|
||||
): FolderUploadFilterResult {
|
||||
const maxBytes = options.maxSizeMB * 1024 * 1024;
|
||||
return files.filter((file) => {
|
||||
const valid: File[] = [];
|
||||
let oversizeCount = 0;
|
||||
let unsupportedCount = 0;
|
||||
for (const file of files) {
|
||||
const rel = file.webkitRelativePath || file.name;
|
||||
if (isHiddenPath(rel)) return false;
|
||||
if (file.size > maxBytes) return false;
|
||||
if (isHiddenPath(rel)) continue; // hidden: silent drop
|
||||
if (file.size > maxBytes) {
|
||||
oversizeCount++;
|
||||
continue;
|
||||
}
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (!ext || !options.allowedExtensions.includes(ext)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!ext || !options.allowedExtensions.includes(ext)) {
|
||||
unsupportedCount++;
|
||||
continue;
|
||||
}
|
||||
valid.push(file);
|
||||
}
|
||||
return { valid, oversizeCount, unsupportedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user