diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index e36dc201..5d69b9d9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -79,7 +79,9 @@ public class MySkillAppService { MySkillFilter normalizedFilter = parseFilter(filter); Page skillPage; - if (normalizedFilter == MySkillFilter.ALL + if (normalizedFilter == MySkillFilter.HIDDEN) { + skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles); + } else if (normalizedFilter == MySkillFilter.ALL && (keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) { skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); @@ -92,6 +94,28 @@ public class MySkillAppService { return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); } + private Page listHiddenSkills(int page, + int size, + String keyword, + String namespace, + java.util.Set platformRoles) { + PageRequest pageable = PageRequest.of(page, size); + if (!platformRoles.contains("SUPER_ADMIN")) { + return Page.empty(pageable); + } + if ((keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) { + return skillRepository.findByHiddenTrue(pageable); + } + + Long namespaceId = resolveNamespaceId(namespace); + String normalizedKeyword = normalizeKeyword(keyword); + List filtered = skillRepository.findByHiddenTrue().stream() + .filter(skill -> matchesNamespace(skill, namespaceId)) + .filter(skill -> matchesKeyword(skill, normalizedKeyword)) + .toList(); + return page(filtered, page, size); + } + public PageResponse listMyStars(String userId, int page, int size) { Page starPage = skillStarRepository.findByUserId( userId, @@ -150,17 +174,10 @@ public class MySkillAppService { List skills = skillRepository.findByOwnerId(userId); // Namespace filter - Long namespaceId = null; - if (namespace != null && !namespace.isBlank()) { - namespaceId = namespaceRepository.findBySlug(namespace.trim()) - .map(Namespace::getId) - .orElse(-1L); - } + Long namespaceId = resolveNamespaceId(namespace); final Long finalNamespaceId = namespaceId; - String normalizedKeyword = keyword != null && !keyword.isBlank() - ? keyword.trim().toLowerCase(java.util.Locale.ROOT) - : null; + String normalizedKeyword = normalizeKeyword(keyword); List filtered = skills.stream() .filter(skill -> matchesNamespace(skill, finalNamespaceId)) @@ -168,13 +185,28 @@ public class MySkillAppService { .filter(skill -> matchesFilter(skill, filter, platformRoles)) .toList(); - int fromIndex = Math.min(page * size, filtered.size()); - int toIndex = Math.min(fromIndex + size, filtered.size()); - return new PageImpl<>( - filtered.subList(fromIndex, toIndex), - PageRequest.of(page, size), - filtered.size() - ); + return page(filtered, page, size); + } + + private Long resolveNamespaceId(String namespace) { + if (namespace == null || namespace.isBlank()) { + return null; + } + return namespaceRepository.findBySlug(namespace.trim()) + .map(Namespace::getId) + .orElse(-1L); + } + + private String normalizeKeyword(String keyword) { + return keyword != null && !keyword.isBlank() + ? keyword.trim().toLowerCase(java.util.Locale.ROOT) + : null; + } + + private Page page(List skills, int page, int size) { + int fromIndex = Math.min(page * size, skills.size()); + int toIndex = Math.min(fromIndex + size, skills.size()); + return new PageImpl<>(skills.subList(fromIndex, toIndex), PageRequest.of(page, size), skills.size()); } private boolean matchesNamespace(Skill skill, Long namespaceId) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 7c8bbb3e..acd58494 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -230,14 +230,12 @@ class MySkillAppServiceTest { } @Test - void listMySkills_filtersHiddenOnlyForSuperAdmins() { - Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "user-1"); + void listMySkills_listsHiddenSkillsAcrossOwnersOnlyForSuperAdmins() { + Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "publisher"); hiddenSkill.setHidden(true); - Skill publishedSkill = createSkill(4L, 101L, "published-skill", "user-1"); - SkillVersion hiddenVersion = createVersion(3L, 33L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); - given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(hiddenSkill, publishedSkill)); - given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(hiddenVersion)); + given(skillRepository.findByHiddenTrue(PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(hiddenSkill), PageRequest.of(0, 10), 1)); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); var regularUserResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("USER")); @@ -248,6 +246,14 @@ class MySkillAppServiceTest { assertThat(superAdminResult.items()).extracting("slug").containsExactly("hidden-skill"); } + @Test + void listMySkills_doesNotExposeHiddenSkillsToSkillAdmins() { + var result = service.listMySkills("skill-admin", 0, 10, "HIDDEN", Set.of("SKILL_ADMIN")); + + assertThat(result.total()).isZero(); + assertThat(result.items()).isEmpty(); + } + @Test void listMySkills_exposesRejectedOwnerPreviewInSummary() { Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 520ebed1..a6d4c760 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -22,6 +22,8 @@ public interface SkillRepository { void delete(Skill skill); List findByOwnerId(String ownerId); Page findByOwnerId(String ownerId, Pageable pageable); + List findByHiddenTrue(); + Page findByHiddenTrue(Pageable pageable); void incrementDownloadCount(Long skillId); void incrementSubscriptionCount(Long skillId); void decrementSubscriptionCount(Long skillId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index ed209a50..f1502007 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -87,6 +87,16 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return delegate.findByOwnerId(ownerId, pageable); } + @Override + public List findByHiddenTrue() { + return delegate.findByHiddenTrue(); + } + + @Override + public Page findByHiddenTrue(Pageable pageable) { + return delegate.findByHiddenTrue(pageable); + } + @Override public void incrementDownloadCount(Long skillId) { delegate.incrementDownloadCount(skillId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 1b2e0246..18e97fa0 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -34,12 +34,24 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); List findByOwnerId(String ownerId); Page findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable); + List findByHiddenTrueOrderByUpdatedAtDesc(); + Page findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable); @Override default Page findByOwnerId(String ownerId, Pageable pageable) { return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable); } + @Override + default List findByHiddenTrue() { + return findByHiddenTrueOrderByUpdatedAtDesc(); + } + + @Override + default Page findByHiddenTrue(Pageable pageable) { + return findByHiddenTrueOrderByUpdatedAtDesc(pageable); + } + @Modifying @Transactional @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") diff --git a/web/src/features/admin/use-admin-skills.ts b/web/src/features/admin/use-admin-skills.ts new file mode 100644 index 00000000..4ca3053e --- /dev/null +++ b/web/src/features/admin/use-admin-skills.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' + +export function useRestoreHiddenSkill() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (skillId: number) => adminApi.unhideSkill(skillId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5cae8447..ae77f032 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "Skill restored", "unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.", "unarchiveErrorTitle": "Failed to restore skill", + "restoreHidden": "Restore visibility", + "restoreHiddenConfirmTitle": "Restore hidden skill", + "restoreHiddenConfirmDescription": "\"{{skill}}\" will return to ordinary product surfaces.", + "restoreHiddenSuccessTitle": "Skill visibility restored", + "restoreHiddenSuccessDescription": "\"{{skill}}\" was removed from the hidden skill list.", + "restoreHiddenErrorTitle": "Failed to restore hidden skill", "withdrawReview": "Withdraw Review", "withdrawConfirmTitle": "Withdraw upload", "withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index fc2299c6..cb32b413 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "Скилл восстановлен", "unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.", "unarchiveErrorTitle": "Не удалось восстановить скилл", + "restoreHidden": "Вернуть видимость", + "restoreHiddenConfirmTitle": "Восстановить скрытый скилл", + "restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.", + "restoreHiddenSuccessTitle": "Видимость скилла восстановлена", + "restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.", + "restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл", "withdrawReview": "Отозвать рецензию", "withdrawConfirmTitle": "Отозвать загрузку", "withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a49969b8..5e67b612 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "技能已恢复", "unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。", "unarchiveErrorTitle": "恢复技能失败", + "restoreHidden": "恢复显示", + "restoreHiddenConfirmTitle": "确认恢复隐藏技能", + "restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。", + "restoreHiddenSuccessTitle": "技能已恢复显示", + "restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。", + "restoreHiddenErrorTitle": "恢复隐藏技能失败", "withdrawReview": "撤销审核", "withdrawConfirmTitle": "确认撤销上传", "withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index c82faf93..5833836d 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -5,11 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = [] const useMySkillsMock = vi.fn() +const searchMock: { filter?: string } = {} +let isSuperAdmin = false vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, useLocation: () => ({ pathname: '/dashboard/skills' }), - useSearch: () => ({}), + useSearch: () => searchMock, })) vi.mock('react-i18next', async () => { @@ -23,7 +25,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: (role: string) => role === 'SUPER_ADMIN' && isSuperAdmin }), })) vi.mock('@/shared/ui/button', () => ({ @@ -79,6 +81,10 @@ vi.mock('@/shared/hooks/use-debounce', () => ({ useDebounce: (value: string) => value, })) +vi.mock('@/features/admin/use-admin-skills', () => ({ + useRestoreHiddenSkill: () => ({ mutateAsync: vi.fn() }), +})) + vi.mock('@/shared/lib/skill-lifecycle', () => ({ getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), @@ -114,6 +120,8 @@ describe('MySkillsPage', () => { beforeEach(() => { navigateMock.mockReset() buttonRecords.length = 0 + delete searchMock.filter + isSuperAdmin = false useMySkillsMock.mockReturnValue({ data: { items: [ @@ -216,6 +224,17 @@ describe('MySkillsPage', () => { }) }) + it('shows a direct restore action instead of owner actions in the hidden governance view', () => { + searchMock.filter = 'HIDDEN' + isSuperAdmin = true + + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(true) + expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false) + expect(buttonRecords.some((button) => button.label === 'mySkills.archive')).toBe(false) + }) + it('exports a named component function', () => { expect(typeof MySkillsPage).toBe('function') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 4dbe4765..a3a739f2 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -14,6 +14,7 @@ import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/sh import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' import { useDebounce } from '@/shared/hooks/use-debounce' +import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' @@ -63,6 +64,7 @@ export function MySkillsPage() { const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) + const [restoreHiddenTarget, setRestoreHiddenTarget] = useState<{ skillId: number; name: string } | null>(null) const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ @@ -102,6 +104,7 @@ export function MySkillsPage() { const unarchiveMutation = useUnarchiveSkill() const withdrawMutation = useWithdrawSkillReview() const submitPromotionMutation = useSubmitPromotion() + const restoreHiddenMutation = useRestoreHiddenSkill() const handleSkillClick = (namespace: string, slug: string) => { navigate({ @@ -270,6 +273,23 @@ export function MySkillsPage() { } } + const handleRestoreHiddenSkill = async () => { + if (!restoreHiddenTarget) { + return + } + try { + await restoreHiddenMutation.mutateAsync(restoreHiddenTarget.skillId) + toast.success( + t('mySkills.restoreHiddenSuccessTitle'), + t('mySkills.restoreHiddenSuccessDescription', { skill: restoreHiddenTarget.name }), + ) + setRestoreHiddenTarget(null) + } catch (error) { + toast.error(t('mySkills.restoreHiddenErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -362,8 +382,8 @@ export function MySkillsPage() { return ( handleSkillClick(skill.namespace, skill.slug)} + className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`} + onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)} >
@@ -407,7 +427,18 @@ export function MySkillsPage() {
- {skill.status !== 'ARCHIVED' && ( + {filter === 'HIDDEN' ? ( + + ) : skill.status !== 'ARCHIVED' && (
@@ -515,6 +548,19 @@ export function MySkillsPage() { /> )} + { + if (!open) { + setRestoreHiddenTarget(null) + } + }} + title={t('mySkills.restoreHiddenConfirmTitle')} + description={restoreHiddenTarget ? t('mySkills.restoreHiddenConfirmDescription', { skill: restoreHiddenTarget.name }) : ''} + confirmText={t('mySkills.restoreHidden')} + onConfirm={handleRestoreHiddenSkill} + /> + {