Compare commits
3
Commits
4e3f56af79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7627d70e | ||
|
|
28eab4ae5f | ||
|
|
dcd77c6b35 |
@@ -22,13 +22,16 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, delete, func, or_, select, update
|
||||
from sqlalchemy import Select, and_, delete, func, insert, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..db import bind_tenant
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_attachment import Attachment
|
||||
from ..models_knowledge import (
|
||||
DOC_TYPES,
|
||||
KNOWLEDGE_STATUSES,
|
||||
@@ -37,7 +40,7 @@ from ..models_knowledge import (
|
||||
KnowledgeCategory,
|
||||
KnowledgeDoc,
|
||||
)
|
||||
from ..models_task import Activity, Item, ItemTag, Tag
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
@@ -95,6 +98,14 @@ class StatusActionIn(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
class BulkMoveIn(BaseModel):
|
||||
item_ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||||
# 不传 = 留在当前工作区;传了就必须是自己是成员的工作区
|
||||
target_workspace_id: uuid.UUID | None = None
|
||||
# 不传 = 移动到"未分类"
|
||||
category_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
async def _load_doc(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False
|
||||
@@ -164,6 +175,11 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _row_values(row: Any) -> dict[str, Any]:
|
||||
"""把一行 ORM 对象摊平成可原样 INSERT 的字典(跨租户搬家时用)。"""
|
||||
return {column.name: getattr(row, column.name) for column in row.__table__.columns}
|
||||
|
||||
|
||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
@@ -653,6 +669,294 @@ async def search(
|
||||
return ok({"query": q, "mode": mode, "items": hits, "has_more": False})
|
||||
|
||||
|
||||
@router.post("/knowledge/bulk-move", summary="批量移动知识到目录 / 其它工作区")
|
||||
async def bulk_move_knowledge(body: BulkMoveIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
"""把多条知识一起搬到另一个目录,或搬到另一个工作区的某个目录。
|
||||
|
||||
同一工作区只是改 `knowledge_doc.category_id`;跨工作区是"真的搬家":
|
||||
RLS 下一条 UPDATE 没法既让源租户看得见旧行、又让目标租户放行新行,
|
||||
所以在同一个事务里先读完整快照、按 id 删掉源条目(外键级联清子表),
|
||||
再切到目标租户按同样的 id 写回。失败整体回滚,不会出现"搬了一半"。
|
||||
"""
|
||||
ws_id = ctx.ws_id
|
||||
target_ws = body.target_workspace_id or ws_id
|
||||
item_ids = list(dict.fromkeys(body.item_ids))
|
||||
cross = target_ws != ws_id
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Item, KnowledgeDoc)
|
||||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||||
.where(
|
||||
Item.id.in_(item_ids),
|
||||
Item.workspace_id == ws_id,
|
||||
Item.kind == "knowledge",
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
found = {item.id for item, _doc in rows}
|
||||
missing = [str(i) for i in item_ids if i not in found]
|
||||
if missing:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_NOT_FOUND,
|
||||
"有知识不存在或已被删除",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
if cross:
|
||||
# 移出工作区等于把它从团队手里拿走,和删除同一条权限线
|
||||
for item, _doc in rows:
|
||||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||||
raise AppError(
|
||||
ErrorCode.INSUFFICIENT_ROLE,
|
||||
f"「{item.title}」只有作者本人或管理员能移出工作区",
|
||||
)
|
||||
# member 表的策略允许看到"自己的成员记录",所以不用切租户就能确认目标身份
|
||||
target_member = (
|
||||
await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == target_ws,
|
||||
Member.user_id == ctx.user.id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target_member is None or target_member.role not in ("owner", "admin", "member"):
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "你不是目标工作区的成员,不能移入")
|
||||
# 目标租户的工作区和分类要切过去才看得见(RLS 只放行当前租户)
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
try:
|
||||
target_ws_row = await session.get(Workspace, target_ws)
|
||||
if target_ws_row is None or target_ws_row.deleted_at is not None:
|
||||
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "目标工作区不存在")
|
||||
category = await _resolve_category(session, target_ws, body.category_id)
|
||||
finally:
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
else:
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
|
||||
category_id = category.id if category else None
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not cross:
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.item_id.in_(item_ids))
|
||||
.values(category_id=category_id)
|
||||
)
|
||||
# 分类也算这条知识的"样子"变了,推进 version,免得别处拿着旧版本号撞 4009
|
||||
await session.execute(
|
||||
update(Item)
|
||||
.where(Item.workspace_id == ws_id, Item.id.in_(item_ids))
|
||||
.values(version=Item.version + 1, updated_at=now)
|
||||
)
|
||||
for item, _doc in rows:
|
||||
await _log(
|
||||
session,
|
||||
item,
|
||||
ctx.user.id,
|
||||
"knowledge_moved",
|
||||
{"category_id": str(category_id) if category_id else None},
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
# ── 跨工作区:先快照,再删,再在目标租户写回 ──────────────────────────
|
||||
item_values = [_row_values(item) for item, _doc in rows]
|
||||
doc_values = [_row_values(doc) for _item, doc in rows]
|
||||
moved = {item.id for item, _doc in rows}
|
||||
|
||||
tag_pairs = (
|
||||
await session.execute(
|
||||
select(ItemTag.item_id, Tag.name, Tag.color)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(moved))
|
||||
)
|
||||
).all()
|
||||
tags_by_item: dict[uuid.UUID, list[str]] = {}
|
||||
tag_colors: dict[str, str] = {}
|
||||
for item_id, name, color in tag_pairs:
|
||||
tags_by_item.setdefault(item_id, []).append(name)
|
||||
tag_colors.setdefault(name, color or "")
|
||||
|
||||
comment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Comment).where(Comment.workspace_id == ws_id, Comment.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
activity_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Activity).where(Activity.workspace_id == ws_id, Activity.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
attachment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Attachment).where(
|
||||
Attachment.workspace_id == ws_id, Attachment.item_id.in_(moved)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
chunk_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
|
||||
# 被搬走的知识如果还是本工作区别的知识的"来源",先把引用改成手动来源:
|
||||
# 否则删除源条目时 FK SET NULL 会撞上 source_kind 的 CHECK 约束。
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.source_item_id.in_(moved),
|
||||
KnowledgeDoc.item_id.not_in(moved),
|
||||
)
|
||||
.values(source_item_id=None, source_kind="manual")
|
||||
)
|
||||
await session.execute(delete(Item).where(Item.workspace_id == ws_id, Item.id.in_(moved)))
|
||||
session.expunge_all()
|
||||
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_out",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"target_workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
existing_tags = {
|
||||
tag.name: tag
|
||||
for tag in (
|
||||
await session.execute(select(Tag).where(Tag.workspace_id == target_ws))
|
||||
).scalars()
|
||||
}
|
||||
created_tags = [
|
||||
Tag(workspace_id=target_ws, name=name, color=color)
|
||||
for name, color in tag_colors.items()
|
||||
if name not in existing_tags
|
||||
]
|
||||
if created_tags:
|
||||
session.add_all(created_tags)
|
||||
await session.flush()
|
||||
tag_ids = {name: tag.id for name, tag in existing_tags.items()}
|
||||
tag_ids.update({tag.name: tag.id for tag in created_tags})
|
||||
|
||||
for values in item_values:
|
||||
values["workspace_id"] = target_ws
|
||||
for values in doc_values:
|
||||
values["workspace_id"] = target_ws
|
||||
values["category_id"] = category_id
|
||||
source_item_id = values.get("source_item_id")
|
||||
if source_item_id is not None and source_item_id not in moved:
|
||||
# 来源留在原工作区,目标工作区里不该留一条跨租户的外键
|
||||
values["source_item_id"] = None
|
||||
values["source_kind"] = "manual"
|
||||
|
||||
await session.execute(insert(Item), item_values)
|
||||
await session.execute(insert(KnowledgeDoc), doc_values)
|
||||
|
||||
item_tag_values = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"workspace_id": target_ws,
|
||||
"item_id": item_id,
|
||||
"tag_id": tag_ids[name],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"version": 1,
|
||||
}
|
||||
for item_id, names in tags_by_item.items()
|
||||
for name in names
|
||||
]
|
||||
# 子表全部跟着换租户:RLS 的 WITH CHECK 只看新行,漏一个就整单回滚
|
||||
for child_values in (comment_values, activity_values, attachment_values, chunk_values):
|
||||
for row in child_values:
|
||||
row["workspace_id"] = target_ws
|
||||
for table, values in (
|
||||
(ItemTag, item_tag_values),
|
||||
(Comment, comment_values),
|
||||
(Activity, activity_values),
|
||||
(Attachment, attachment_values),
|
||||
(Chunk, chunk_values),
|
||||
):
|
||||
if values:
|
||||
await session.execute(insert(table), values)
|
||||
|
||||
for item_id in item_ids:
|
||||
session.add(
|
||||
Activity(
|
||||
workspace_id=target_ws,
|
||||
item_id=item_id,
|
||||
actor_id=ctx.user.id,
|
||||
action="knowledge_moved",
|
||||
payload={
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=target_ws,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_in",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge/{item_id}", summary="知识详情")
|
||||
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
|
||||
@@ -538,3 +538,227 @@ async def test_tags_endpoint_counts_documents(client: AsyncClient) -> None:
|
||||
)
|
||||
counts = {row["name"]: row["count"] for row in got["items"]}
|
||||
assert counts == {"发布": 2, "回滚": 1}
|
||||
|
||||
|
||||
async def _category(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, name: str
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||
json={"name": name},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _note(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, title: str, **extra: Any
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge",
|
||||
json={"title": title, "doc_type": "note", **extra},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_move(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, body: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/bulk-move",
|
||||
json=body,
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_into_category_of_same_workspace(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "整理知识")
|
||||
cat = await _category(client, owner, ws["id"], "制度")
|
||||
first = await _note(client, owner, ws["id"], "报销要贴发票原件")
|
||||
second = await _note(client, owner, ws["id"], "请假提前一天提")
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
ws["id"],
|
||||
{"item_ids": [first["id"], second["id"]], "category_id": cat["id"]},
|
||||
)
|
||||
assert sorted(moved["moved"]) == sorted([first["id"], second["id"]])
|
||||
assert moved["workspace_id"] == ws["id"]
|
||||
assert moved["category_id"] == cat["id"]
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
params={"category_id": cat["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert sorted(i["id"] for i in listed["items"]) == sorted([first["id"], second["id"]])
|
||||
assert all(i["category"]["id"] == cat["id"] for i in listed["items"])
|
||||
|
||||
# 不动目录也照样能移(移动到"未分类")
|
||||
uncategorized = await _bulk_move(client, owner, ws["id"], {"item_ids": [first["id"]]})
|
||||
assert uncategorized["category_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_across_workspaces_keeps_tags_and_children(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "来源库")
|
||||
dst = await create_workspace(client, owner, "目标库")
|
||||
cat = await _category(client, owner, dst["id"], "运维")
|
||||
doc = await _note(client, owner, src["id"], "重启网关的顺序", tag_names=["运维", "网关"])
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
src["id"],
|
||||
{
|
||||
"item_ids": [doc["id"]],
|
||||
"target_workspace_id": dst["id"],
|
||||
"category_id": cat["id"],
|
||||
},
|
||||
)
|
||||
assert moved["workspace_id"] == dst["id"]
|
||||
assert moved["category_id"] == cat["id"]
|
||||
|
||||
gone = await client.get(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
assert gone.json()["code"] == 7001
|
||||
|
||||
got = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
assert got["id"] == doc["id"]
|
||||
assert got["knowledge"]["category_id"] == cat["id"]
|
||||
assert sorted(t["name"] for t in got["tags"]) == ["网关", "运维"]
|
||||
assert got["knowledge"]["source_kind"] == "manual"
|
||||
|
||||
# 标签本身也在目标工作区里建好了,不是只在关联表里挂了个空壳
|
||||
tags = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{dst['id']}/knowledge/tags", headers=owner["headers"])
|
||||
)
|
||||
assert {row["name"] for row in tags["items"]} == {"运维", "网关"}
|
||||
|
||||
# 历史事件也搬过来了,不然详情页会像凭空出现的一篇
|
||||
actions = {e["action"] for e in got["activities"]}
|
||||
assert {"knowledge_created", "knowledge_moved"} <= actions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_ticket_source_becomes_manual(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "有来源的库")
|
||||
dst = await create_workspace(client, owner, "接手的库")
|
||||
helper = await _member(client, owner, src)
|
||||
task = await _task(client, owner, src["id"], "网关老是超时")
|
||||
ticket = await _finish_ticket(client, owner, src["id"], helper, task)
|
||||
# 验收通过时系统已经自动沉淀了一份草稿,直接拿它
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge",
|
||||
params={"source_item_id": ticket["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
doc = listed["items"][0]
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
src["id"],
|
||||
{"item_ids": [doc["id"]], "target_workspace_id": dst["id"]},
|
||||
)
|
||||
assert moved["moved"] == [doc["id"]]
|
||||
|
||||
got = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
# 目标库里没有那张工单,来源必须降级成手动,否则指向一个看不见的 id
|
||||
assert got["knowledge"]["source_kind"] == "manual"
|
||||
assert got["knowledge"]["source_item_id"] is None
|
||||
assert got["source"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_requires_membership_in_target(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "自己的库")
|
||||
stranger = await login(client)
|
||||
dst = await create_workspace(client, stranger, "别人的库")
|
||||
doc = await _note(client, owner, src["id"], "我的笔记")
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]], "target_workspace_id": dst["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_rejects_foreign_category_and_missing_docs(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "甲库")
|
||||
dst = await create_workspace(client, owner, "乙库")
|
||||
src_cat = await _category(client, owner, src["id"], "甲的分类")
|
||||
doc = await _note(client, owner, src["id"], "一篇")
|
||||
|
||||
foreign = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={
|
||||
"item_ids": [doc["id"]],
|
||||
"target_workspace_id": dst["id"],
|
||||
"category_id": src_cat["id"],
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert foreign.json()["code"] == 7006
|
||||
|
||||
missing = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": ["00000000-0000-0000-0000-000000000001"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert missing.json()["code"] == 7001
|
||||
assert missing.json()["data"]["missing"] == ["00000000-0000-0000-0000-000000000001"]
|
||||
|
||||
# 移出后源库再移一次要报 7001,而不是静默成功
|
||||
await _bulk_move(
|
||||
client, owner, src["id"], {"item_ids": [doc["id"]], "target_workspace_id": dst["id"]}
|
||||
)
|
||||
again = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert again.json()["code"] == 7001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_cannot_touch_another_tenants_knowledge(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "甲的工作区")
|
||||
doc = await _note(client, owner, ws["id"], "甲的秘密")
|
||||
outsider = await login(client)
|
||||
own = await create_workspace(client, outsider, "乙的工作区")
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{own['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]]},
|
||||
headers=outsider["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 7001
|
||||
|
||||
@@ -106,6 +106,13 @@ export const Search = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Refresh = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M20 12a8 8 0 1 1-2.6-5.9" />
|
||||
<path d="M20 4v4h-4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Plus = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
|
||||
+297
-6
@@ -1,10 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Check, Link2, More, Paperclip, Pencil, Plus, Search, Sparkle, Spinner, Trash } from "../components/icons";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Link2,
|
||||
More,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Sparkle,
|
||||
Spinner,
|
||||
Trash,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -13,6 +27,7 @@ import {
|
||||
Sheet,
|
||||
SkeletonRows,
|
||||
StatusPill,
|
||||
useToast,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import {
|
||||
@@ -81,6 +96,11 @@ export function KnowledgePage() {
|
||||
const [tags, setTags] = useState<KnowledgeTagCount[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [catBusy, setCatBusy] = useState(false);
|
||||
/** 批量移动:先进入选择模式,再挑目标工作区/目录 */
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [moving, setMoving] = useState(false);
|
||||
const { push: toast } = useToast();
|
||||
const reqId = useRef(0);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
@@ -243,6 +263,8 @@ export function KnowledgePage() {
|
||||
setHits(null);
|
||||
return;
|
||||
}
|
||||
// 检索结果是一组切片,和列表里的条目不是同一批东西,选中态在这里没有意义
|
||||
cancelSelect();
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -286,6 +308,51 @@ export function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新:列表、分类、标签一起重拉;检索态下重跑检索 */
|
||||
async function refresh() {
|
||||
if (hits !== null) await runSearch();
|
||||
else await load(null);
|
||||
await Promise.all([loadCategories(), loadTags()]);
|
||||
}
|
||||
|
||||
function startSelect() {
|
||||
setHits(null);
|
||||
setSelecting(true);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function cancelSelect() {
|
||||
setSelecting(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
setSelected((prev) =>
|
||||
prev.size === docs.length && docs.length > 0 ? new Set() : new Set(docs.map((d) => d.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function onMoved(result: { moved: string[]; workspace_id: string; category_id: string | null }) {
|
||||
setMoving(false);
|
||||
cancelSelect();
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
void loadTags();
|
||||
toast(
|
||||
`已移动 ${result.moved.length} 篇知识到${result.workspace_id === wsId ? "当前工作区" : "另一个工作区"}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
|
||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
||||
|
||||
@@ -332,6 +399,7 @@ export function KnowledgePage() {
|
||||
selected={catFilter}
|
||||
loading={loading}
|
||||
onSelect={(value) => {
|
||||
cancelSelect();
|
||||
setCatFilter(value);
|
||||
setTagFilter(null);
|
||||
setHits(null);
|
||||
@@ -339,6 +407,7 @@ export function KnowledgePage() {
|
||||
tags={tags}
|
||||
tagSelected={tagFilter}
|
||||
onSelectTag={(name) => {
|
||||
cancelSelect();
|
||||
setTagFilter((prev) => (prev === name ? null : name));
|
||||
setHits(null);
|
||||
}}
|
||||
@@ -355,7 +424,11 @@ export function KnowledgePage() {
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
onClick={() => {
|
||||
// 切视图后列表换了,旧选中项可能已经不在眼前,别让人搬走看不见的东西
|
||||
cancelSelect();
|
||||
setTab(t.key);
|
||||
}}
|
||||
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
@@ -366,17 +439,64 @@ export function KnowledgePage() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{loading ? (
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
||||
<span className="inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
||||
<Spinner size={12} />
|
||||
载入中…
|
||||
</span>
|
||||
) : tab === "draft" && pendingCount > 0 ? (
|
||||
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
|
||||
<span className="hidden text-2xs text-ink-400 sm:block">
|
||||
{pendingCount} 份草稿等你核对
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loading || searching}
|
||||
onClick={() => void refresh()}
|
||||
title="重新加载列表、分类和标签"
|
||||
>
|
||||
<Refresh size={13} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={selecting ? "subtle" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => (selecting ? cancelSelect() : startSelect())}
|
||||
title="选中多篇知识,一起移动到目录或其它工作区"
|
||||
>
|
||||
<Check size={13} />
|
||||
{selecting ? "退出批量" : "批量移动"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selecting && hits === null ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50/60 px-3 py-2">
|
||||
<span className="text-xs font-medium text-primary-800">
|
||||
已选 {selected.size} / {docs.length} 篇
|
||||
</span>
|
||||
<button className="link text-2xs" onClick={toggleSelectAll}>
|
||||
{selected.size === docs.length && docs.length > 0 ? "取消全选" : "全选列表"}
|
||||
</button>
|
||||
<span className="text-2xs text-ink-400">点卡片选中或取消</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={cancelSelect}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setMoving(true)}
|
||||
>
|
||||
<ArrowRight size={13} />
|
||||
移动到…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
@@ -483,9 +603,30 @@ export function KnowledgePage() {
|
||||
? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) }
|
||||
: undefined
|
||||
}
|
||||
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
|
||||
className={`card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop ${
|
||||
selecting && selected.has(d.id) ? "ring-2 ring-primary-300" : ""
|
||||
}`}
|
||||
>
|
||||
{selecting ? (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(d.id)}
|
||||
aria-label={`选择 ${d.title}`}
|
||||
onClick={() => toggleSelect(d.id)}
|
||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition ${
|
||||
selected.has(d.id)
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-line-300 bg-white text-transparent hover:border-primary-400"
|
||||
}`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => (selecting ? toggleSelect(d.id) : openDoc(d.id))}
|
||||
>
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => openDoc(d.id)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||
@@ -535,6 +676,7 @@ export function KnowledgePage() {
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
{selecting ? null : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDoc(d.id)}>
|
||||
打开
|
||||
@@ -549,6 +691,7 @@ export function KnowledgePage() {
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -575,10 +718,158 @@ export function KnowledgePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{moving && (
|
||||
<MoveKnowledgeSheet
|
||||
wsId={wsId}
|
||||
itemIds={[...selected]}
|
||||
onClose={() => setMoving(false)}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
)}
|
||||
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移动:选目标工作区 + 目标目录。
|
||||
* 跨工作区时标签、评论、附件一起搬走;工单来源记成手动创建,免得目标库里挂一条看不见的工单。
|
||||
*/
|
||||
function MoveKnowledgeSheet({
|
||||
wsId,
|
||||
itemIds,
|
||||
onClose,
|
||||
onMoved,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemIds: string[];
|
||||
onClose: () => void;
|
||||
onMoved: (result: { moved: string[]; workspace_id: string; category_id: string | null }) => void;
|
||||
}) {
|
||||
const { workspaces } = useAuth();
|
||||
const [targetWs, setTargetWs] = useState(wsId);
|
||||
const [cats, setCats] = useState<KnowledgeCategoryNode[]>([]);
|
||||
const [catId, setCatId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 当前工作区排第一:多数时候只是整理目录,不该先让人跨区找自己
|
||||
const targets = useMemo(() => {
|
||||
const current = workspaces.find((w) => w.id === wsId);
|
||||
const rest = workspaces.filter((w) => w.id !== wsId && w.role !== "guest");
|
||||
return current ? [current, ...rest] : rest;
|
||||
}, [workspaces, wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setCatId("");
|
||||
api
|
||||
.get<{ items: KnowledgeCategoryNode[] }>(`/workspaces/${targetWs}/knowledge/categories`)
|
||||
.then((data) => {
|
||||
if (alive) setCats(data.items);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof ApiError ? e.message : "分类载入失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [targetWs]);
|
||||
|
||||
async function submit() {
|
||||
if (busy || itemIds.length === 0) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.post<{
|
||||
moved: string[];
|
||||
workspace_id: string;
|
||||
category_id: string | null;
|
||||
}>(`/workspaces/${wsId}/knowledge/bulk-move`, {
|
||||
item_ids: itemIds,
|
||||
target_workspace_id: targetWs === wsId ? null : targetWs,
|
||||
category_id: catId || null,
|
||||
});
|
||||
onMoved(res);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiError ? e.message : "移动失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title="移动知识"
|
||||
subtitle={`共 ${itemIds.length} 篇`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || loading || itemIds.length === 0}
|
||||
>
|
||||
{busy ? "移动中…" : "确认移动"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error ? <InlineAlert>{error}</InlineAlert> : null}
|
||||
<label className="block">
|
||||
<span className="label">目标工作区</span>
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={targetWs}
|
||||
onChange={(e) => setTargetWs(e.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{targets.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
{w.id === wsId ? "(当前)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">目标目录</span>
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={catId}
|
||||
onChange={(e) => setCatId(e.target.value)}
|
||||
disabled={busy || loading}
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{cats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{"\u3000".repeat(Math.max(0, c.depth - 1))}
|
||||
{c.full_path || c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{targetWs !== wsId ? (
|
||||
<InlineAlert tone="info">
|
||||
会连同标签、评论、附件一起搬到目标工作区;原工单来源会记为手动创建,原工作区的通知与关联不再保留。
|
||||
</InlineAlert>
|
||||
) : null}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */
|
||||
function ColorSwatch({
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user