Merge pull request #829 from iflytek/codex/feat/issue-819-hidden-skill-restore-20260908

fix(governance): restore hidden skill management
This commit is contained in:
XiaoSeS 2026-09-08 14:35:22 +08:00 committed by GitHub
commit 25e18e047c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 392 additions and 64 deletions

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.skill.Skill;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/** Query-side access for the platform hidden-skill governance list. */
public interface HiddenSkillQueryRepository {
Page<Skill> search(String keyword, Long namespaceId, Pageable pageable);
}

View file

@ -0,0 +1,72 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.skill.Skill;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
/**
* Executes the pageable, presentation-specific hidden-skill search without widening the domain
* repository with platform-governance query concerns.
*/
@Repository
public class JpaHiddenSkillQueryRepository implements HiddenSkillQueryRepository {
private final EntityManager entityManager;
public JpaHiddenSkillQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Page<Skill> search(String keyword, Long namespaceId, Pageable pageable) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<Skill> query = builder.createQuery(Skill.class);
Root<Skill> root = query.from(Skill.class);
query.select(root)
.where(buildPredicates(keyword, namespaceId, builder, root).toArray(Predicate[]::new))
.orderBy(builder.desc(root.get("updatedAt")));
TypedQuery<Skill> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
Root<Skill> countRoot = countQuery.from(Skill.class);
countQuery.select(builder.count(countRoot))
.where(buildPredicates(keyword, namespaceId, builder, countRoot).toArray(Predicate[]::new));
return new PageImpl<>(typedQuery.getResultList(), pageable, entityManager.createQuery(countQuery).getSingleResult());
}
private List<Predicate> buildPredicates(String keyword,
Long namespaceId,
CriteriaBuilder builder,
Root<Skill> root) {
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.isTrue(root.get("hidden")));
if (namespaceId != null) {
predicates.add(builder.equal(root.get("namespaceId"), namespaceId));
}
if (StringUtils.hasText(keyword)) {
String pattern = "%" + keyword.trim().toLowerCase(Locale.ROOT) + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("displayName")), pattern),
builder.like(builder.lower(root.get("slug")), pattern),
builder.like(builder.lower(root.get("summary")), pattern)
));
}
return predicates;
}
}

View file

@ -63,6 +63,33 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
.toList();
}
@Override
public List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills) {
if (skills.isEmpty()) {
return List.of();
}
Map<Long, Namespace> namespacesById = namespaceRepository.findByIdIn(
skills.stream().map(Skill::getNamespaceId).distinct().toList())
.stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
Map<String, UserAccount> ownersById = userAccountRepository == null
? Map.of()
: userAccountRepository.findByIdIn(skills.stream().map(Skill::getOwnerId).distinct().toList())
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
Map<Long, SkillLifecycleProjectionService.Projection> projections =
skillLifecycleProjectionService.projectPublishedSummaries(skills);
return skills.stream()
.map(skill -> toSummaryResponse(
skill,
namespacesById,
ownersById,
projections.get(skill.getId()),
false
))
.toList();
}
private SkillSummaryResponse toSummaryResponse(Skill skill,
String currentUserId,
Map<Long, Namespace> namespacesById,
@ -76,6 +103,21 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
if (skill.getOwnerId().equals(currentUserId)) {
projection = skillLifecycleProjectionService.projectForOwnerSummary(skill);
}
return toSummaryResponse(
skill,
namespacesById,
ownersById,
projection,
canSubmitPromotion(skill, projection.publishedVersion(), namespace)
);
}
private SkillSummaryResponse toSummaryResponse(Skill skill,
Map<Long, Namespace> namespacesById,
Map<String, UserAccount> ownersById,
SkillLifecycleProjectionService.Projection projection,
boolean canSubmitPromotion) {
Namespace namespace = namespacesById.get(skill.getNamespaceId());
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
@ -97,7 +139,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
ownersById.get(skill.getOwnerId()) != null
? ownersById.get(skill.getOwnerId()).getDisplayName()
: null,
canSubmitPromotion(skill, publishedVersion, namespace),
canSubmitPromotion,
toLifecycleVersion(headlineVersion),
toLifecycleVersion(publishedVersion),
toLifecycleVersion(ownerPreviewVersion),

View file

@ -10,4 +10,6 @@ import java.util.List;
*/
public interface MySkillQueryRepository {
List<SkillSummaryResponse> getSkillSummaries(List<Skill> skills, String currentUserId);
List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills);
}

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.repository.HiddenSkillQueryRepository;
import com.iflytek.skillhub.repository.MySkillQueryRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
@ -37,6 +38,7 @@ public class MySkillAppService {
private final SkillStarRepository skillStarRepository;
private final SkillSubscriptionRepository skillSubscriptionRepository;
private final MySkillQueryRepository mySkillQueryRepository;
private final HiddenSkillQueryRepository hiddenSkillQueryRepository;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
private final NamespaceRepository namespaceRepository;
@ -46,6 +48,7 @@ public class MySkillAppService {
SkillStarRepository skillStarRepository,
SkillSubscriptionRepository skillSubscriptionRepository,
MySkillQueryRepository mySkillQueryRepository,
HiddenSkillQueryRepository hiddenSkillQueryRepository,
SkillLifecycleProjectionService skillLifecycleProjectionService,
NamespaceRepository namespaceRepository) {
this.skillRepository = skillRepository;
@ -53,6 +56,7 @@ public class MySkillAppService {
this.skillStarRepository = skillStarRepository;
this.skillSubscriptionRepository = skillSubscriptionRepository;
this.mySkillQueryRepository = mySkillQueryRepository;
this.hiddenSkillQueryRepository = hiddenSkillQueryRepository;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
this.namespaceRepository = namespaceRepository;
}
@ -79,19 +83,36 @@ public class MySkillAppService {
MySkillFilter normalizedFilter = parseFilter(filter);
Page<Skill> 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));
skillPage = skillRepository.findVisibleByOwnerId(userId, PageRequest.of(page, size));
} else {
skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles);
}
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
List<SkillSummaryResponse> items = normalizedFilter == MySkillFilter.HIDDEN
? mySkillQueryRepository.getHiddenSkillSummaries(skillPage.getContent())
: mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
}
private Page<Skill> listHiddenSkills(int page,
int size,
String keyword,
String namespace,
java.util.Set<String> platformRoles) {
PageRequest pageable = PageRequest.of(page, size);
if (!platformRoles.contains("SUPER_ADMIN")) {
return Page.empty(pageable);
}
Long namespaceId = resolveNamespaceId(namespace);
return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable);
}
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
userId,
@ -150,17 +171,10 @@ public class MySkillAppService {
List<Skill> 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<Skill> filtered = skills.stream()
.filter(skill -> matchesNamespace(skill, finalNamespaceId))
@ -168,13 +182,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<Skill> page(List<Skill> 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) {

View file

@ -2,6 +2,9 @@ package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
@ -132,4 +135,35 @@ class JpaMySkillQueryRepositoryTest {
assertThat(responses.get(0).publishedVersion()).isNotNull();
assertThat(responses.get(0).canSubmitPromotion()).isFalse();
}
@Test
void getHiddenSkillSummaries_batchesPublishedVersionProjection() {
Skill first = new Skill(101L, "first-hidden", "owner-1", SkillVisibility.PUBLIC);
Skill second = new Skill(101L, "second-hidden", "owner-2", SkillVisibility.PUBLIC);
ReflectionTestUtils.setField(first, "id", 10L);
ReflectionTestUtils.setField(second, "id", 20L);
first.setLatestVersionId(110L);
second.setLatestVersionId(120L);
SkillVersion firstVersion = new SkillVersion(10L, "1.0.0", "owner-1");
SkillVersion secondVersion = new SkillVersion(20L, "2.0.0", "owner-2");
firstVersion.setStatus(SkillVersionStatus.PUBLISHED);
secondVersion.setStatus(SkillVersionStatus.PUBLISHED);
ReflectionTestUtils.setField(firstVersion, "id", 110L);
ReflectionTestUtils.setField(secondVersion, "id", 120L);
Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 101L);
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
given(skillVersionRepository.findByIdIn(List.of(110L, 120L)))
.willReturn(List.of(firstVersion, secondVersion));
var responses = repository.getHiddenSkillSummaries(List.of(first, second));
assertThat(responses).extracting("slug").containsExactly("first-hidden", "second-hidden");
assertThat(responses).allMatch(response -> !response.canSubmitPromotion());
verify(skillVersionRepository).findByIdIn(List.of(110L, 120L));
verifyNoMoreInteractions(skillVersionRepository);
verifyNoInteractions(promotionRequestRepository);
}
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.social.SkillStar;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
import com.iflytek.skillhub.repository.JpaMySkillQueryRepository;
import com.iflytek.skillhub.repository.HiddenSkillQueryRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -57,6 +58,9 @@ class MySkillAppServiceTest {
@Mock
private PromotionRequestRepository promotionRequestRepository;
@Mock
private HiddenSkillQueryRepository hiddenSkillQueryRepository;
private MySkillAppService service;
private SkillLifecycleProjectionService skillLifecycleProjectionService;
private JpaMySkillQueryRepository mySkillQueryRepository;
@ -75,6 +79,7 @@ class MySkillAppServiceTest {
skillStarRepository,
skillSubscriptionRepository,
mySkillQueryRepository,
hiddenSkillQueryRepository,
skillLifecycleProjectionService,
namespaceRepository
);
@ -132,7 +137,7 @@ class MySkillAppServiceTest {
ReflectionTestUtils.setField(pendingVersion, "id", 11L);
ReflectionTestUtils.setField(pendingVersion, "createdAt", Instant.parse("2026-03-15T09:30:00Z"));
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion));
@ -165,7 +170,7 @@ class MySkillAppServiceTest {
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
ReflectionTestUtils.setField(namespace, "id", 101L);
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
@ -197,7 +202,7 @@ class MySkillAppServiceTest {
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
ReflectionTestUtils.setField(namespace, "id", 101L);
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
@ -230,14 +235,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(hiddenSkillQueryRepository.search(null, null, 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,12 +251,41 @@ 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_delegatesHiddenFilteringAndPaginationToQueryRepository() {
Skill hiddenSkill = createSkill(7L, 101L, "hidden-agent", "publisher");
hiddenSkill.setHidden(true);
Namespace namespace = namespace(101L, "team-ai");
PageRequest pageRequest = PageRequest.of(1, 5);
given(namespaceRepository.findBySlug("team-ai")).willReturn(Optional.of(namespace));
given(hiddenSkillQueryRepository.search("agent", 101L, pageRequest))
.willReturn(new PageImpl<>(List.of(hiddenSkill), pageRequest, 6));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
var result = service.listMySkills(
"super-admin", 1, 5, "HIDDEN", " Agent ", "team-ai", Set.of("SUPER_ADMIN"));
assertThat(result.total()).isEqualTo(6);
assertThat(result.page()).isEqualTo(1);
assertThat(result.size()).isEqualTo(5);
assertThat(result.items()).extracting("slug").containsExactly("hidden-agent");
}
@Test
void listMySkills_exposesRejectedOwnerPreviewInSummary() {
Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1");
SkillVersion rejectedVersion = createVersion(5L, 55L, "1.1.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillId(5L)).willReturn(List.of(rejectedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
@ -272,7 +304,7 @@ class MySkillAppServiceTest {
SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z");
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdAndStatus(6L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion));

View file

@ -21,7 +21,7 @@ public interface SkillRepository {
void flush();
void delete(Skill skill);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);
Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable);
void incrementDownloadCount(Long skillId);
void incrementSubscriptionCount(Long skillId);
void decrementSubscriptionCount(Long skillId);

View file

@ -83,8 +83,8 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
}
@Override
public Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
return delegate.findByOwnerId(ownerId, pageable);
public Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable) {
return delegate.findVisibleByOwnerId(ownerId, pageable);
}
@Override

View file

@ -33,11 +33,11 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
List<Skill> findByNamespaceIdAndStatusOrderByCreatedAtDesc(Long namespaceId, SkillStatus status);
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
Page<Skill> findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
@Override
default Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable);
default Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable) {
return findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(ownerId, pageable);
}
@Modifying

View file

@ -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'] })
},
})
}

View file

@ -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.",

View file

@ -386,6 +386,12 @@
"unarchiveSuccessTitle": "Скилл восстановлен",
"unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.",
"unarchiveErrorTitle": "Не удалось восстановить скилл",
"restoreHidden": "Вернуть видимость",
"restoreHiddenConfirmTitle": "Восстановить скрытый скилл",
"restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.",
"restoreHiddenSuccessTitle": "Видимость скилла восстановлена",
"restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.",
"restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл",
"withdrawReview": "Отозвать рецензию",
"withdrawConfirmTitle": "Отозвать загрузку",
"withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.",

View file

@ -386,6 +386,12 @@
"unarchiveSuccessTitle": "技能已恢复",
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
"unarchiveErrorTitle": "恢复技能失败",
"restoreHidden": "恢复显示",
"restoreHiddenConfirmTitle": "确认恢复隐藏技能",
"restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。",
"restoreHiddenSuccessTitle": "技能已恢复显示",
"restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。",
"restoreHiddenErrorTitle": "恢复隐藏技能失败",
"withdrawReview": "撤销审核",
"withdrawConfirmTitle": "确认撤销上传",
"withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。",

View file

@ -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,25 @@ 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('does not render the restore action for a non-super-admin even when the hidden filter is in the URL', () => {
searchMock.filter = 'HIDDEN'
renderToStaticMarkup(createElement(MySkillsPage))
expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(false)
})
it('exports a named component function', () => {
expect(typeof MySkillsPage).toBe('function')
})

View file

@ -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<typeof search>, options?: { replace?: boolean }) => {
navigate({
@ -96,12 +98,14 @@ export function MySkillsPage() {
const skills = skillPage?.items ?? []
const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1
const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN'))
const canRestoreHidden = filter === 'HIDDEN' && hasRole('SUPER_ADMIN')
const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== ''
const emptyStateKey = getMySkillEmptyStateKey(filter)
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const withdrawMutation = useWithdrawSkillReview()
const submitPromotionMutation = useSubmitPromotion()
const restoreHiddenMutation = useRestoreHiddenSkill()
const handleSkillClick = (namespace: string, slug: string) => {
navigate({
@ -270,6 +274,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 (
<div className="space-y-4 animate-fade-up">
@ -302,24 +323,26 @@ export function MySkillsPage() {
aria-label={t('mySkills.searchPlaceholder')}
className="sm:max-w-md"
/>
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
{filter === 'HIDDEN' ? null : (
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{hasActiveSearch ? (
<Button
type="button"
@ -362,8 +385,8 @@ export function MySkillsPage() {
return (
<Card
key={skill.id}
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => 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)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
@ -407,7 +430,18 @@ export function MySkillsPage() {
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{skill.status !== 'ARCHIVED' && (
{canRestoreHidden ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setRestoreHiddenTarget({ skillId: skill.id, name: skill.displayName })
}}
>
{t('mySkills.restoreHidden')}
</Button>
) : filter === 'HIDDEN' ? null : skill.status !== 'ARCHIVED' && (
<Button
size="sm"
variant="outline"
@ -419,7 +453,7 @@ export function MySkillsPage() {
{t('mySkills.update')}
</Button>
)}
{hasPendingPreview && ownerPreviewVersion ? (
{filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (
<Button
size="sm"
variant="outline"
@ -482,9 +516,11 @@ export function MySkillsPage() {
{t('mySkills.archive')}
</Button>
) : null}
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{filter === 'HIDDEN' ? null : (
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
</div>
</Card>
@ -515,6 +551,19 @@ export function MySkillsPage() {
/>
)}
<ConfirmDialog
open={!!restoreHiddenTarget}
onOpenChange={(open) => {
if (!open) {
setRestoreHiddenTarget(null)
}
}}
title={t('mySkills.restoreHiddenConfirmTitle')}
description={restoreHiddenTarget ? t('mySkills.restoreHiddenConfirmDescription', { skill: restoreHiddenTarget.name }) : ''}
confirmText={t('mySkills.restoreHidden')}
onConfirm={handleRestoreHiddenSkill}
/>
<ConfirmDialog
open={!!promotionTarget}
onOpenChange={(open) => {