feat(knowledge): 知识空间置顶按最近置顶时间排序

后端 list_for_user 返回有序置顶 ID,列表按置顶先后排列;前端抽出 spaceSort,乐观更新时新置顶移到置顶区最前,并覆盖门户侧栏排序。
This commit is contained in:
lkk
2026-07-22 10:15:52 +08:00
parent c475160b42
commit f34a5f4eae
12 changed files with 265 additions and 52 deletions
+5
View File
@@ -288,3 +288,8 @@ sg_test.sh
sg-sync-api-preview.md
sg-sync-api-内部.md
mdf_file_read_count.sh
.dev-stack/
# Local-only 门户联调 (do not commit)
.cursor/rules/local-portal-dev-stack.mdc
scripts/dev-stack.sh
@@ -19,14 +19,20 @@ class KnowledgeSpacePinRepositoryImpl(BaseRepositoryImpl[UserLink, int], Knowled
async def lock_user(self, user_id: int) -> None:
await self.session.exec(select(_USER_TABLE.c.user_id).where(_USER_TABLE.c.user_id == user_id).with_for_update())
async def list_for_user(self, user_id: int) -> set[int]:
async def list_for_user(self, user_id: int) -> list[int]:
result = await self.session.exec(
select(UserLink.type_detail).where(
select(UserLink.type_detail)
.where(
UserLink.user_id == user_id,
UserLink.type == KNOWLEDGE_SPACE_PIN_TYPE,
)
.order_by(UserLink.create_time.desc(), UserLink.id.desc())
)
return {int(type_detail) for type_detail in result.all() if str(type_detail).isdigit()}
ordered: list[int] = []
for type_detail in result.all():
if str(type_detail).isdigit():
ordered.append(int(type_detail))
return ordered
async def add_pin(self, user_id: int, space_id: int) -> bool:
existing = await self.session.exec(
@@ -9,7 +9,8 @@ class KnowledgeSpacePinRepository(BaseRepository[Any, int], ABC):
async def lock_user(self, user_id: int) -> None: ...
@abstractmethod
async def list_for_user(self, user_id: int) -> set[int]: ...
async def list_for_user(self, user_id: int) -> list[int]:
"""Return pinned space ids ordered by create_time DESC (most recent first)."""
@abstractmethod
async def add_pin(self, user_id: int, space_id: int) -> bool: ...
@@ -13,12 +13,17 @@ MAX_PINS_PER_LEVEL = 5
class KnowledgeSpacePinService:
@staticmethod
async def get_pinned_space_ids(user_id: int, visible_space_ids: set[int]) -> set[int]:
async def list_pinned_space_ids(user_id: int, visible_space_ids: set[int]) -> list[int]:
"""Pinned space ids visible to the user, most recently pinned first."""
if not visible_space_ids:
return set()
return []
async with get_async_db_session() as session:
pinned_ids = await KnowledgeSpacePinRepositoryImpl(session).list_for_user(user_id)
return pinned_ids & visible_space_ids
return [space_id for space_id in pinned_ids if space_id in visible_space_ids]
@staticmethod
async def get_pinned_space_ids(user_id: int, visible_space_ids: set[int]) -> set[int]:
return set(await KnowledgeSpacePinService.list_pinned_space_ids(user_id, visible_space_ids))
@classmethod
async def set_pin(
@@ -33,7 +38,7 @@ class KnowledgeSpacePinService:
repository = KnowledgeSpacePinRepositoryImpl(session)
await repository.lock_user(user_id)
pinned_ids = await repository.list_for_user(user_id)
current_ids = pinned_ids & visible_space_ids
current_ids = [item for item in pinned_ids if item in visible_space_ids]
if not is_pinned:
await repository.remove_pin(user_id, space_id)
await session.commit()
@@ -55,19 +60,21 @@ class KnowledgeSpacePinService:
for item in spaces
if (item.get("id") if isinstance(item, dict) else getattr(item, "id", None)) is not None
}
pinned_ids = await cls.get_pinned_space_ids(user_id, visible_ids)
pinned_order = await cls.list_pinned_space_ids(user_id, visible_ids)
pin_rank = {space_id: index for index, space_id in enumerate(pinned_order)}
pinned: list[Any] = []
normal: list[Any] = []
for item in spaces:
item_id = int(item.get("id") if isinstance(item, dict) else item.id)
level = item.get("space_level") if isinstance(item, dict) else getattr(item, "space_level", None)
level_value = getattr(level, "value", level)
is_pinned = level_value != KnowledgeSpaceLevelEnum.PERSONAL.value and item_id in pinned_ids
is_pinned = level_value != KnowledgeSpaceLevelEnum.PERSONAL.value and item_id in pin_rank
if isinstance(item, dict):
item["is_pinned"] = is_pinned
else:
item.is_pinned = is_pinned
(pinned if is_pinned else normal).append(item)
pinned.sort(key=lambda item: pin_rank[int(item.get("id") if isinstance(item, dict) else item.id)])
return pinned + normal
@staticmethod
@@ -42,14 +42,14 @@ async def test_stale_cached_pin_is_reset_before_return():
),
patch.object(
KnowledgeSpacePinService,
"get_pinned_space_ids",
new=AsyncMock(return_value=set()),
) as get_pins,
"list_pinned_space_ids",
new=AsyncMock(return_value=[]),
) as list_pins,
):
result = await service._list_accessible_spaces()
assert result[0].is_pinned is False
get_pins.assert_awaited_once_with(7, {10})
list_pins.assert_awaited_once_with(7, {10})
@pytest.mark.asyncio
@@ -63,8 +63,8 @@ async def test_personal_residual_link_never_marks_list_item_pinned():
]
with patch.object(
KnowledgeSpacePinService,
"get_pinned_space_ids",
new=AsyncMock(return_value={10}),
"list_pinned_space_ids",
new=AsyncMock(return_value=[10]),
):
result = await KnowledgeSpacePinService.apply_pins(spaces, user_id=7)
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta
from unittest.mock import AsyncMock
import pytest
@@ -34,11 +35,42 @@ async def test_list_add_and_remove_are_scoped_and_idempotent(session: AsyncSessi
assert await repository.add_pin(1, 10) is True
assert await repository.add_pin(1, 10) is False
assert await repository.list_for_user(1) == {10}
assert await repository.list_for_user(1) == [10]
assert await repository.remove_pin(1, 999) is False
assert await repository.remove_pin(1, 10) is True
assert await repository.list_for_user(1) == set()
assert await repository.list_for_user(1) == []
@pytest.mark.asyncio
async def test_list_for_user_orders_by_create_time_desc(session: AsyncSession):
repository = KnowledgeSpacePinRepositoryImpl(session)
now = datetime.now()
session.add_all(
[
UserLink(
user_id=1,
type="knowledge_space_pin",
type_detail="10",
create_time=now - timedelta(minutes=2),
),
UserLink(
user_id=1,
type="knowledge_space_pin",
type_detail="11",
create_time=now - timedelta(minutes=1),
),
UserLink(
user_id=1,
type="knowledge_space_pin",
type_detail="12",
create_time=now,
),
]
)
await session.commit()
assert await repository.list_for_user(1) == [12, 11, 10]
@pytest.mark.asyncio
@@ -10,7 +10,8 @@ from bisheng.knowledge.domain.services.knowledge_space_pin_service import Knowle
@pytest.mark.asyncio
async def test_apply_pins_intersects_visible_ids_forces_personal_false_and_keeps_stable_order():
async def test_apply_pins_orders_by_pin_recency_and_forces_personal_false():
# Input list order is 1,2,3,4 but pin order is newest-first: 4 then 2.
spaces = [
SimpleNamespace(id=1, space_level=KnowledgeSpaceLevelEnum.PUBLIC, is_pinned=False),
SimpleNamespace(id=2, space_level=KnowledgeSpaceLevelEnum.PUBLIC, is_pinned=False),
@@ -19,13 +20,13 @@ async def test_apply_pins_intersects_visible_ids_forces_personal_false_and_keeps
]
with patch.object(
KnowledgeSpacePinService,
"get_pinned_space_ids",
new=AsyncMock(return_value={2, 3, 4, 999}),
) as get_pins:
"list_pinned_space_ids",
new=AsyncMock(return_value=[4, 2, 3, 999]),
) as list_pins:
result = await KnowledgeSpacePinService.apply_pins(spaces, user_id=7)
get_pins.assert_awaited_once_with(7, {1, 2, 3, 4})
assert [item.id for item in result] == [2, 4, 1, 3]
list_pins.assert_awaited_once_with(7, {1, 2, 3, 4})
assert [item.id for item in result] == [4, 2, 1, 3]
assert [item.is_pinned for item in result] == [True, True, False, False]
@@ -37,8 +38,8 @@ async def test_apply_pins_supports_public_list_dicts():
]
with patch.object(
KnowledgeSpacePinService,
"get_pinned_space_ids",
new=AsyncMock(return_value={2}),
"list_pinned_space_ids",
new=AsyncMock(return_value=[2]),
):
result = await KnowledgeSpacePinService.apply_pins(spaces, user_id=7)
@@ -46,11 +47,35 @@ async def test_apply_pins_supports_public_list_dicts():
assert [item["is_pinned"] for item in result] == [True, False]
@pytest.mark.asyncio
async def test_apply_pins_re_pin_moves_space_to_front():
"""Pin A then B → [B, A]; unpin A and re-pin A → [A, B]."""
spaces = [
SimpleNamespace(id=1, space_level=KnowledgeSpaceLevelEnum.PUBLIC, is_pinned=False),
SimpleNamespace(id=2, space_level=KnowledgeSpaceLevelEnum.PUBLIC, is_pinned=False),
]
with patch.object(
KnowledgeSpacePinService,
"list_pinned_space_ids",
new=AsyncMock(return_value=[2, 1]),
):
after_pin_b = await KnowledgeSpacePinService.apply_pins(spaces, user_id=7)
assert [item.id for item in after_pin_b] == [2, 1]
with patch.object(
KnowledgeSpacePinService,
"list_pinned_space_ids",
new=AsyncMock(return_value=[1, 2]),
):
after_re_pin_a = await KnowledgeSpacePinService.apply_pins(spaces, user_id=7)
assert [item.id for item in after_re_pin_a] == [1, 2]
@pytest.mark.asyncio
async def test_set_pin_rejects_sixth_visible_pin_without_writing():
repository = SimpleNamespace(
lock_user=AsyncMock(),
list_for_user=AsyncMock(return_value=set(range(1, 6))),
list_for_user=AsyncMock(return_value=list(range(1, 6))),
add_pin=AsyncMock(),
remove_pin=AsyncMock(),
)
@@ -87,7 +112,7 @@ async def test_set_pin_rejects_sixth_visible_pin_without_writing():
async def test_set_pin_is_idempotent_for_existing_pin_even_at_limit():
repository = SimpleNamespace(
lock_user=AsyncMock(),
list_for_user=AsyncMock(return_value=set(range(1, 6))),
list_for_user=AsyncMock(return_value=list(range(1, 6))),
add_pin=AsyncMock(),
remove_pin=AsyncMock(),
)
@@ -12,6 +12,7 @@ import { NotificationSeverity } from "~/common";
import { useToastContext } from "~/Providers";
import { useLocalize } from "~/hooks";
import { extractKnowledgeActionErrorMessage } from "../errorUtils";
import { applyPinOrderToSpaceList } from "../sidebar/spaceSort";
interface UseSpaceActionsOptions {
activeSpaceId?: string;
@@ -142,9 +143,10 @@ export function useSpaceActions({
return;
}
// Optimistic update
const updater = (list: KnowledgeSpace[]) => list.map(s => s.id === spaceId ? { ...s, isPinned: pinned } : s);
updateAllCaches(updater);
const previousGrouped = queryClient.getQueryData<GroupedKnowledgeSpaces>(["knowledgeSpaces", "grouped"]);
// Optimistic update: newly pinned spaces move to the front of the pinned block.
updateAllCaches((list) => applyPinOrderToSpaceList(list, spaceId, pinned));
if (activeSpaceId === spaceId) {
const space = targetList.find(s => s.id === spaceId);
@@ -156,9 +158,11 @@ export function useSpaceActions({
queryClient.invalidateQueries({ queryKey: ["knowledgeSpaces"] });
showToast({ message: pinned ? localize("com_knowledge.pinned") : localize("com_knowledge.unpinned"), severity: NotificationSeverity.SUCCESS });
} catch {
// Rollback
const rollback = (list: KnowledgeSpace[]) => list.map(s => s.id === spaceId ? { ...s, isPinned: !pinned } : s);
updateAllCaches(rollback);
if (previousGrouped) {
queryClient.setQueryData(["knowledgeSpaces", "grouped"], previousGrouped);
} else {
updateAllCaches((list) => applyPinOrderToSpaceList(list, spaceId, !pinned));
}
if (activeSpaceId === spaceId) {
const space = targetList.find(s => s.id === spaceId);
if (space) onSpaceSelect({ ...space, isPinned: !pinned });
@@ -463,12 +463,35 @@ export default function PortalKnowledgeWorkbench() {
showToast({ message: "最多置顶 5 个知识库", severity: NotificationSeverity.INFO });
return;
}
const queryKey = ["knowledgeSpaces", "level", group.level];
const previousSpaces = queryClient.getQueryData<KnowledgeSpace[]>(queryKey);
if (previousSpaces) {
const target = previousSpaces.find((item) => item.id === space.id);
if (target) {
const updated = { ...target, isPinned: pinned };
const others = previousSpaces.filter((item) => item.id !== space.id);
queryClient.setQueryData<KnowledgeSpace[]>(
queryKey,
pinned
? [updated, ...others]
: [
...others.filter((item) => item.isPinned),
updated,
...others.filter((item) => !item.isPinned),
],
);
}
}
setActiveSpace((prev) => prev?.id === space.id ? { ...prev, isPinned: pinned } : prev);
try {
await pinSpaceApi(space.id, pinned);
setActiveSpace((prev) => prev?.id === space.id ? { ...prev, isPinned: pinned } : prev);
await queryClient.invalidateQueries({ queryKey: ["knowledgeSpaces"] });
showToast({ message: pinned ? "已置顶" : "已取消置顶", severity: NotificationSeverity.SUCCESS });
} catch {
if (previousSpaces) {
queryClient.setQueryData(queryKey, previousSpaces);
}
setActiveSpace((prev) => prev?.id === space.id ? { ...prev, isPinned: !pinned } : prev);
showToast({ message: "操作失败", severity: NotificationSeverity.ERROR });
}
}, [queryClient, showToast]);
@@ -17,6 +17,9 @@ import {
hasKnowledgeSpacePermission,
useKnowledgeSpaceActionPermissions,
} from "../hooks/useKnowledgeSpacePermissions";
import { sortKnowledgeSpacesForSection } from "./spaceSort";
export { applyPinOrderToSpaceList, sortKnowledgeSpacesForSection } from "./spaceSort";
interface KnowledgeSpaceSidebarProps {
activeSpaceId?: string;
@@ -64,23 +67,6 @@ function getSortLabel(sort: SpaceSortType, localize: any) {
return sort === SpaceSortType.NAME ? localize("com_knowledge.name") : localize("com_knowledge.recently_updated");
}
export function sortKnowledgeSpacesForSection(
spaces: KnowledgeSpace[],
sortBy: SpaceSortType,
): KnowledgeSpace[] {
return [...spaces].sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1;
}
if (sortBy === SpaceSortType.NAME) {
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
}
const timeA = Date.parse(a.updatedAt || "") || 0;
const timeB = Date.parse(b.updatedAt || "") || 0;
return timeB - timeA;
});
}
export function getNextSectionSorts(
currentSorts: Record<SpaceLevel, SpaceSortType>,
level: SpaceLevel,
@@ -0,0 +1,82 @@
import { KnowledgeSpace, SpaceLevel, SpaceSortType } from "~/api/knowledge";
import { applyPinOrderToSpaceList, sortKnowledgeSpacesForSection } from "./spaceSort";
function space(
id: string,
overrides: Partial<KnowledgeSpace> = {},
): KnowledgeSpace {
return {
id,
name: id,
description: "",
icon: "",
visibility: "private" as KnowledgeSpace["visibility"],
isReleased: true,
isPinned: false,
spaceLevel: SpaceLevel.PUBLIC,
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("sortKnowledgeSpacesForSection", () => {
it("keeps relative order among pinned spaces", () => {
const input = [
space("b", { isPinned: true, name: "B", updatedAt: "2026-01-02T00:00:00Z" }),
space("a", { isPinned: true, name: "A", updatedAt: "2026-01-03T00:00:00Z" }),
space("c", { isPinned: false, name: "C" }),
];
const byName = sortKnowledgeSpacesForSection(input, SpaceSortType.NAME);
expect(byName.map((item) => item.id)).toEqual(["b", "a", "c"]);
const byTime = sortKnowledgeSpacesForSection(input, SpaceSortType.UPDATE_TIME);
expect(byTime.map((item) => item.id)).toEqual(["b", "a", "c"]);
});
it("still floats pinned spaces above unpinned ones", () => {
const input = [
space("c", { isPinned: false, name: "C" }),
space("a", { isPinned: true, name: "A" }),
space("b", { isPinned: false, name: "B" }),
];
expect(
sortKnowledgeSpacesForSection(input, SpaceSortType.NAME).map((item) => item.id),
).toEqual(["a", "b", "c"]);
});
});
describe("applyPinOrderToSpaceList", () => {
it("moves a newly pinned space to the front", () => {
const input = [
space("a", { isPinned: true }),
space("b", { isPinned: false }),
space("c", { isPinned: false }),
];
expect(applyPinOrderToSpaceList(input, "c", true).map((item) => item.id)).toEqual([
"c",
"a",
"b",
]);
expect(applyPinOrderToSpaceList(input, "c", true)[0].isPinned).toBe(true);
});
it("places an unpinned space after the remaining pinned block", () => {
const input = [
space("b", { isPinned: true }),
space("a", { isPinned: true }),
space("c", { isPinned: false }),
];
expect(applyPinOrderToSpaceList(input, "a", false).map((item) => item.id)).toEqual([
"b",
"a",
"c",
]);
expect(applyPinOrderToSpaceList(input, "a", false).find((item) => item.id === "a")?.isPinned).toBe(
false,
);
});
});
@@ -0,0 +1,42 @@
import { KnowledgeSpace, SpaceSortType } from "~/api/knowledge";
export function sortKnowledgeSpacesForSection(
spaces: KnowledgeSpace[],
sortBy: SpaceSortType,
): KnowledgeSpace[] {
return [...spaces].sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1;
}
// Preserve API / optimistic pin-recency order within the pinned block.
if (a.isPinned && b.isPinned) {
return 0;
}
if (sortBy === SpaceSortType.NAME) {
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
}
const timeA = Date.parse(a.updatedAt || "") || 0;
const timeB = Date.parse(b.updatedAt || "") || 0;
return timeB - timeA;
});
}
/** Move a space to the front of its list when pinning; keep relative order otherwise. */
export function applyPinOrderToSpaceList(
list: KnowledgeSpace[],
spaceId: string,
pinned: boolean,
): KnowledgeSpace[] {
const target = list.find((space) => space.id === spaceId);
if (!target) {
return list;
}
const updated = { ...target, isPinned: pinned };
const others = list.filter((space) => space.id !== spaceId);
if (pinned) {
return [updated, ...others];
}
const pinnedItems = others.filter((space) => space.isPinned);
const unpinnedItems = others.filter((space) => !space.isPinned);
return [...pinnedItems, updated, ...unpinnedItems];
}