diff --git a/.gitignore b/.gitignore index df9d92d91..8ebe6699a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/backend/bisheng/knowledge/domain/repositories/implementations/knowledge_space_pin_repository_impl.py b/src/backend/bisheng/knowledge/domain/repositories/implementations/knowledge_space_pin_repository_impl.py index 42a301e7c..b7ddaaa64 100644 --- a/src/backend/bisheng/knowledge/domain/repositories/implementations/knowledge_space_pin_repository_impl.py +++ b/src/backend/bisheng/knowledge/domain/repositories/implementations/knowledge_space_pin_repository_impl.py @@ -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( diff --git a/src/backend/bisheng/knowledge/domain/repositories/interfaces/knowledge_space_pin_repository.py b/src/backend/bisheng/knowledge/domain/repositories/interfaces/knowledge_space_pin_repository.py index a02bdf714..6b6c7ee3f 100644 --- a/src/backend/bisheng/knowledge/domain/repositories/interfaces/knowledge_space_pin_repository.py +++ b/src/backend/bisheng/knowledge/domain/repositories/interfaces/knowledge_space_pin_repository.py @@ -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: ... diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_pin_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_pin_service.py index 47727071c..41a975d28 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_pin_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_pin_service.py @@ -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 diff --git a/src/backend/test/knowledge/test_knowledge_space_pin_lists.py b/src/backend/test/knowledge/test_knowledge_space_pin_lists.py index 1a5b57996..05f18f176 100644 --- a/src/backend/test/knowledge/test_knowledge_space_pin_lists.py +++ b/src/backend/test/knowledge/test_knowledge_space_pin_lists.py @@ -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) diff --git a/src/backend/test/knowledge/test_knowledge_space_pin_repository.py b/src/backend/test/knowledge/test_knowledge_space_pin_repository.py index 293c61d3d..81f1f9fbd 100644 --- a/src/backend/test/knowledge/test_knowledge_space_pin_repository.py +++ b/src/backend/test/knowledge/test_knowledge_space_pin_repository.py @@ -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 diff --git a/src/backend/test/knowledge/test_knowledge_space_pin_service.py b/src/backend/test/knowledge/test_knowledge_space_pin_service.py index 13c0f1a88..514f064ab 100644 --- a/src/backend/test/knowledge/test_knowledge_space_pin_service.py +++ b/src/backend/test/knowledge/test_knowledge_space_pin_service.py @@ -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(), ) diff --git a/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.ts b/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.ts index 361037536..e17c99d76 100644 --- a/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.ts +++ b/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.ts @@ -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(["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 }); diff --git a/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.tsx b/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.tsx index 965f8af07..89ee2a590 100644 --- a/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.tsx +++ b/src/frontend/client/src/pages/knowledge/portal/PortalKnowledgeWorkbench.tsx @@ -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(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( + 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]); diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx index b1988570f..cd8b8cc6f 100644 --- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx +++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx @@ -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, level: SpaceLevel, diff --git a/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.test.ts b/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.test.ts new file mode 100644 index 000000000..df5c8428d --- /dev/null +++ b/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.test.ts @@ -0,0 +1,82 @@ +import { KnowledgeSpace, SpaceLevel, SpaceSortType } from "~/api/knowledge"; +import { applyPinOrderToSpaceList, sortKnowledgeSpacesForSection } from "./spaceSort"; + +function space( + id: string, + overrides: Partial = {}, +): 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, + ); + }); +}); diff --git a/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.ts b/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.ts new file mode 100644 index 000000000..59224c879 --- /dev/null +++ b/src/frontend/client/src/pages/knowledge/sidebar/spaceSort.ts @@ -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]; +}