mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
feat(my-skills): add keyword search, namespace filter and clickable pagination
Add comprehensive filtering and search capabilities to the My Skills page: - Keyword search: search by skill name, slug, or description - Namespace filter: filter skills by namespace - Clickable pagination: page number buttons with smart ellipsis - State preservation: sync search state to URL, restore when returning from detail page - Debounced search: 300ms debounce to avoid excessive queries - Fix: hide stale rejected preview badge when newer version is published Backend changes: - MySkillAppService: add keyword and namespace filtering logic - SkillLifecycleProjectionService: only show preview versions newer than published - MeController: add keyword and namespace query parameters - 6 new test cases covering search and filter scenarios Frontend changes: - my-skills.tsx: search input, namespace dropdown, URL state sync - pagination.tsx: clickable page numbers with ellipsis - use-user-queries.ts: prevent flicker on query transitions - skill-detail.tsx: remove invalid rejected badge display - router.tsx: URL parameter validation - i18n: add search-related translation keys Synced from SAAS commits: - 939fa749 (feat: search and filters) - dc14df6c (fix: search flicker) - 0168ea81 (fix: rejected badge) - c9eefa93 (fix: stale preview) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
1ec93db0d6
commit
2fc200a00b
13 changed files with 438 additions and 81 deletions
|
|
@ -34,6 +34,8 @@ public class MeController extends BaseApiController {
|
|||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String filter,
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String namespace,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
|
|
@ -41,7 +43,7 @@ public class MeController extends BaseApiController {
|
|||
|
||||
return ok(
|
||||
"response.success.read",
|
||||
mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles())
|
||||
mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
|
|
@ -36,6 +38,7 @@ public class MySkillAppService {
|
|||
private final SkillSubscriptionRepository skillSubscriptionRepository;
|
||||
private final MySkillQueryRepository mySkillQueryRepository;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
|
||||
public MySkillAppService(
|
||||
SkillRepository skillRepository,
|
||||
|
|
@ -43,17 +46,19 @@ public class MySkillAppService {
|
|||
SkillStarRepository skillStarRepository,
|
||||
SkillSubscriptionRepository skillSubscriptionRepository,
|
||||
MySkillQueryRepository mySkillQueryRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService) {
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
NamespaceRepository namespaceRepository) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillStarRepository = skillStarRepository;
|
||||
this.skillSubscriptionRepository = skillSubscriptionRepository;
|
||||
this.mySkillQueryRepository = mySkillQueryRepository;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySkills(String userId, int page, int size) {
|
||||
return listMySkills(userId, page, size, null, java.util.Set.of());
|
||||
return listMySkills(userId, page, size, null, null, null, java.util.Set.of());
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySkills(String userId,
|
||||
|
|
@ -61,10 +66,27 @@ public class MySkillAppService {
|
|||
int size,
|
||||
String filter,
|
||||
java.util.Set<String> platformRoles) {
|
||||
return listMySkills(userId, page, size, filter, null, null, platformRoles);
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySkills(String userId,
|
||||
int page,
|
||||
int size,
|
||||
String filter,
|
||||
String keyword,
|
||||
String namespace,
|
||||
java.util.Set<String> platformRoles) {
|
||||
MySkillFilter normalizedFilter = parseFilter(filter);
|
||||
Page<Skill> skillPage = normalizedFilter == MySkillFilter.ALL
|
||||
? skillRepository.findByOwnerId(userId, PageRequest.of(page, size))
|
||||
: filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles);
|
||||
|
||||
Page<Skill> skillPage;
|
||||
if (normalizedFilter == MySkillFilter.ALL
|
||||
&& (keyword == null || keyword.isBlank())
|
||||
&& (namespace == null || namespace.isBlank())) {
|
||||
skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
|
||||
} else {
|
||||
skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles);
|
||||
}
|
||||
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
|
||||
|
||||
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
|
||||
|
|
@ -118,15 +140,34 @@ public class MySkillAppService {
|
|||
return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
|
||||
}
|
||||
|
||||
private Page<Skill> filterSkillsByLifecycle(String userId,
|
||||
int page,
|
||||
int size,
|
||||
MySkillFilter filter,
|
||||
java.util.Set<String> platformRoles) {
|
||||
private Page<Skill> filterSkills(String userId,
|
||||
int page,
|
||||
int size,
|
||||
MySkillFilter filter,
|
||||
String keyword,
|
||||
String namespace,
|
||||
java.util.Set<String> platformRoles) {
|
||||
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);
|
||||
}
|
||||
|
||||
final Long finalNamespaceId = namespaceId;
|
||||
String normalizedKeyword = keyword != null && !keyword.isBlank()
|
||||
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
|
||||
: null;
|
||||
|
||||
List<Skill> filtered = skills.stream()
|
||||
.filter(skill -> matchesNamespace(skill, finalNamespaceId))
|
||||
.filter(skill -> matchesKeyword(skill, normalizedKeyword))
|
||||
.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<>(
|
||||
|
|
@ -136,6 +177,35 @@ public class MySkillAppService {
|
|||
);
|
||||
}
|
||||
|
||||
private boolean matchesNamespace(Skill skill, Long namespaceId) {
|
||||
if (namespaceId == null) {
|
||||
return true;
|
||||
}
|
||||
if (namespaceId == -1L) {
|
||||
return false;
|
||||
}
|
||||
return skill.getNamespaceId().equals(namespaceId);
|
||||
}
|
||||
|
||||
private boolean matchesKeyword(Skill skill, String keyword) {
|
||||
if (keyword == null) {
|
||||
return true;
|
||||
}
|
||||
String displayName = skill.getDisplayName() != null ? skill.getDisplayName().toLowerCase(java.util.Locale.ROOT) : "";
|
||||
String slug = skill.getSlug() != null ? skill.getSlug().toLowerCase(java.util.Locale.ROOT) : "";
|
||||
String summary = skill.getSummary() != null ? skill.getSummary().toLowerCase(java.util.Locale.ROOT) : "";
|
||||
|
||||
return displayName.contains(keyword) || slug.contains(keyword) || summary.contains(keyword);
|
||||
}
|
||||
|
||||
private Page<Skill> filterSkillsByLifecycle(String userId,
|
||||
int page,
|
||||
int size,
|
||||
MySkillFilter filter,
|
||||
java.util.Set<String> platformRoles) {
|
||||
return filterSkills(userId, page, size, filter, null, null, platformRoles);
|
||||
}
|
||||
|
||||
private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set<String> platformRoles) {
|
||||
if (filter == MySkillFilter.HIDDEN) {
|
||||
return platformRoles.contains("SUPER_ADMIN") && skill.isHidden();
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ class MySkillAppServiceTest {
|
|||
skillStarRepository,
|
||||
skillSubscriptionRepository,
|
||||
mySkillQueryRepository,
|
||||
skillLifecycleProjectionService
|
||||
skillLifecycleProjectionService,
|
||||
namespaceRepository
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +266,107 @@ class MySkillAppServiceTest {
|
|||
assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_hidesStaleRejectedVersionOlderThanPublished() {
|
||||
Skill skill = createSkill(6L, 101L, "recovered-skill", "user-1");
|
||||
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)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var result = service.listMySkills("user-1", 0, 10);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED");
|
||||
assertThat(result.items().get(0).headlineVersion().version()).isEqualTo("2.0.0");
|
||||
assertThat(result.items().get(0).ownerPreviewVersion()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_filtersByKeywordAcrossDisplayNameSlugAndSummary() {
|
||||
Skill alpha = createSkill(1L, 101L, "alpha-tool", "user-1");
|
||||
alpha.setDisplayName("Alpha Assistant");
|
||||
Skill beta = createSkill(2L, 101L, "beta-tool", "user-1");
|
||||
beta.setDisplayName("Beta Tool");
|
||||
beta.setSummary("This tool helps with alpha testing");
|
||||
Skill gamma = createSkill(3L, 101L, "gamma-tool", "user-1");
|
||||
gamma.setDisplayName("Gamma Service");
|
||||
SkillVersion publishedVersion = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(alpha, beta, gamma));
|
||||
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(publishedVersion));
|
||||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of());
|
||||
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of());
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var result = service.listMySkills("user-1", 0, 10, null, "alpha", null, Set.of("USER"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.items()).extracting("slug")
|
||||
.containsExactlyInAnyOrder("alpha-tool", "beta-tool");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_filtersByNamespaceSlug() {
|
||||
Skill aiSkill = createSkill(1L, 101L, "ai-tool", "user-1");
|
||||
Skill mlSkill = createSkill(2L, 102L, "ml-tool", "user-1");
|
||||
SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiSkill, mlSkill));
|
||||
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1));
|
||||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2));
|
||||
given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai")));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var result = service.listMySkills("user-1", 0, 10, null, null, "team-ai", Set.of("USER"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.items()).extracting("slug").containsExactly("ai-tool");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_returnsEmptyWhenNamespaceSlugNotFound() {
|
||||
Skill skill = createSkill(1L, 101L, "ai-tool", "user-1");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill));
|
||||
given(namespaceRepository.findBySlug("missing-namespace")).willReturn(java.util.Optional.empty());
|
||||
|
||||
var result = service.listMySkills("user-1", 0, 10, null, null, "missing-namespace", Set.of("USER"));
|
||||
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_combinesKeywordNamespaceAndStatusFilters() {
|
||||
Skill aiAlpha = createSkill(1L, 101L, "ai-alpha", "user-1");
|
||||
aiAlpha.setDisplayName("AI Alpha");
|
||||
Skill aiBeta = createSkill(2L, 101L, "ai-beta", "user-1");
|
||||
aiBeta.setDisplayName("AI Beta");
|
||||
Skill mlAlpha = createSkill(3L, 102L, "ml-alpha", "user-1");
|
||||
mlAlpha.setDisplayName("ML Alpha");
|
||||
SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
|
||||
SkillVersion v3 = createVersion(3L, 30L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiAlpha, aiBeta, mlAlpha));
|
||||
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1));
|
||||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2));
|
||||
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(v3));
|
||||
given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai")));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var result = service.listMySkills("user-1", 0, 10, "PUBLISHED", "alpha", "team-ai", Set.of("USER"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.items()).extracting("slug").containsExactly("ai-alpha");
|
||||
}
|
||||
|
||||
|
||||
private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) {
|
||||
Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC);
|
||||
skill.setDisplayName(slug);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ public class SkillLifecycleProjectionService {
|
|||
ResolutionMode resolutionMode
|
||||
) {}
|
||||
|
||||
private static final Comparator<SkillVersion> RECENCY = Comparator
|
||||
.comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
|
||||
public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) {
|
||||
|
|
@ -46,22 +50,26 @@ public class SkillLifecycleProjectionService {
|
|||
}
|
||||
|
||||
public Projection projectForViewer(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
|
||||
VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles));
|
||||
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
|
||||
ResolutionMode resolutionMode = headlineVersion == null
|
||||
? ResolutionMode.NONE
|
||||
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
|
||||
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
|
||||
SkillVersion published = resolvePublishedVersion(skill);
|
||||
SkillVersion preview = canManage(skill, currentUserId, userNsRoles)
|
||||
? resolveNewerNonPublishedVersion(skill, published)
|
||||
: null;
|
||||
return buildProjection(published, preview);
|
||||
}
|
||||
|
||||
public Projection projectForOwnerSummary(Skill skill) {
|
||||
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
|
||||
VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill));
|
||||
SkillVersion published = resolvePublishedVersion(skill);
|
||||
SkillVersion preview = resolveNewerNonPublishedVersion(skill, published);
|
||||
return buildProjection(published, preview);
|
||||
}
|
||||
|
||||
private Projection buildProjection(SkillVersion published, SkillVersion preview) {
|
||||
VersionProjection publishedVersion = toProjection(published);
|
||||
VersionProjection ownerPreviewVersion = toProjection(preview);
|
||||
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
|
||||
ResolutionMode resolutionMode = headlineVersion == null
|
||||
? ResolutionMode.NONE
|
||||
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
|
||||
ResolutionMode resolutionMode = headlineVersion == null ? ResolutionMode.NONE
|
||||
: publishedVersion != null ? ResolutionMode.PUBLISHED
|
||||
: ResolutionMode.OWNER_PREVIEW;
|
||||
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
|
||||
}
|
||||
|
||||
|
|
@ -116,27 +124,19 @@ public class SkillLifecycleProjectionService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the newest non-published version the owner can preview.
|
||||
* Includes PENDING_REVIEW, REJECTED, DRAFT, SCANNING, SCAN_FAILED — any status
|
||||
* that isn't already covered by the published projection and isn't yanked.
|
||||
* Returns the newest non-published version (PENDING_REVIEW, REJECTED, DRAFT, SCANNING,
|
||||
* SCAN_FAILED) that represents a NEW round of work layered on top of the current published
|
||||
* version. A non-published version that is older than the published version is treated as
|
||||
* settled history (e.g. an early rejected attempt later superseded by a published release)
|
||||
* and is intentionally not surfaced, so the owner does not see a stale preview/rejected badge
|
||||
* next to an already-published skill.
|
||||
*/
|
||||
private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (!canManage(skill, currentUserId, userNsRoles)) {
|
||||
return null;
|
||||
}
|
||||
private SkillVersion resolveNewerNonPublishedVersion(Skill skill, SkillVersion publishedVersion) {
|
||||
return skillVersionRepository.findBySkillId(skill.getId()).stream()
|
||||
.filter(v -> v.getStatus() != SkillVersionStatus.PUBLISHED
|
||||
&& v.getStatus() != SkillVersionStatus.YANKED)
|
||||
.max(versionComparator())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private SkillVersion resolveNewestNonPublishedVersion(Skill skill) {
|
||||
List<SkillVersion> versions = skillVersionRepository.findBySkillId(skill.getId());
|
||||
return versions.stream()
|
||||
.filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED
|
||||
&& version.getStatus() != SkillVersionStatus.YANKED)
|
||||
.max(versionComparator())
|
||||
.filter(version -> publishedVersion == null || RECENCY.compare(version, publishedVersion) > 0)
|
||||
.max(RECENCY)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1024,13 +1024,19 @@ export const governanceApi = {
|
|||
}
|
||||
|
||||
export const meApi = {
|
||||
async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
|
||||
async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('page', String(params?.page ?? 0))
|
||||
searchParams.set('size', String(params?.size ?? 10))
|
||||
if (params?.filter) {
|
||||
searchParams.set('filter', params.filter)
|
||||
}
|
||||
if (params?.q) {
|
||||
searchParams.set('q', params.q)
|
||||
}
|
||||
if (params?.namespace) {
|
||||
searchParams.set('namespace', params.namespace)
|
||||
}
|
||||
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,12 @@ const dashboardSkillsRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/skills',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number; q?: string; namespace?: string; filter?: string } => ({
|
||||
page: typeof search.page === 'number' ? search.page : undefined,
|
||||
q: typeof search.q === 'string' && search.q ? search.q : undefined,
|
||||
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined,
|
||||
filter: typeof search.filter === 'string' && search.filter ? search.filter : undefined,
|
||||
}),
|
||||
component: MySkillsPage,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,12 @@
|
|||
"mySkills": {
|
||||
"title": "My Skills",
|
||||
"subtitle": "Manage your published skills",
|
||||
"searchPlaceholder": "Search by name, slug, or description",
|
||||
"namespaceFilterLabel": "Filter by namespace",
|
||||
"namespaceFilterAll": "All namespaces",
|
||||
"clearSearch": "Clear filters",
|
||||
"emptySearchTitle": "No matching skills",
|
||||
"emptySearchDescription": "Try adjusting your keyword or switching namespace.",
|
||||
"filters": {
|
||||
"ALL": "All",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
|
|
@ -1277,7 +1283,8 @@
|
|||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"pagePrefix": "Page",
|
||||
"pageSuffix": ""
|
||||
"pageSuffix": "",
|
||||
"goToPage": "Go to page {{page}}"
|
||||
},
|
||||
"user": {
|
||||
"menu": {
|
||||
|
|
|
|||
|
|
@ -339,6 +339,12 @@
|
|||
"mySkills": {
|
||||
"title": "我的技能",
|
||||
"subtitle": "管理你发布的技能",
|
||||
"searchPlaceholder": "搜索技能名称、Slug 或描述",
|
||||
"namespaceFilterLabel": "按命名空间过滤",
|
||||
"namespaceFilterAll": "全部命名空间",
|
||||
"clearSearch": "清除筛选",
|
||||
"emptySearchTitle": "未找到匹配的技能",
|
||||
"emptySearchDescription": "试试调整关键字或切换命名空间",
|
||||
"filters": {
|
||||
"ALL": "全部",
|
||||
"PENDING_REVIEW": "待审核",
|
||||
|
|
@ -1278,7 +1284,8 @@
|
|||
"prev": "上一页",
|
||||
"next": "下一页",
|
||||
"pagePrefix": "第",
|
||||
"pageSuffix": "页"
|
||||
"pageSuffix": "页",
|
||||
"goToPage": "第 {{page}} 页"
|
||||
},
|
||||
"user": {
|
||||
"menu": {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
|
||||
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 { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { buildReturnTo } from '@/shared/lib/auth-route'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const ALL_NAMESPACES_VALUE = '__all_namespaces__'
|
||||
|
||||
/**
|
||||
* Dashboard page for skills owned by the current user.
|
||||
|
|
@ -36,18 +42,62 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending'
|
|||
|
||||
export function MySkillsPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const search = useSearch({ from: '/dashboard/skills' })
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
const [page, setPage] = useState(0)
|
||||
const [filter, setFilter] = useState<MySkillFilter>('ALL')
|
||||
|
||||
// The URL is the source of truth for page / filter / namespace / keyword so the
|
||||
// search context survives navigating into a skill and back via the returnTo link.
|
||||
const page = search.page ?? 0
|
||||
const filter = (search.filter as MySkillFilter) ?? 'ALL'
|
||||
const namespaceFilter = search.namespace ?? ''
|
||||
const keyword = search.q ?? ''
|
||||
|
||||
// Keep an instant-feedback copy of the keyword input, debounced before it is
|
||||
// pushed to the URL so each keystroke does not create a history entry or query.
|
||||
const [keywordInput, setKeywordInput] = useState(keyword)
|
||||
const debouncedKeyword = useDebounce(keywordInput.trim(), 300)
|
||||
|
||||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
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 { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter })
|
||||
|
||||
const updateSearch = (next: Partial<typeof search>, options?: { replace?: boolean }) => {
|
||||
navigate({
|
||||
to: '/dashboard/skills',
|
||||
search: (prev) => ({ ...prev, ...next }),
|
||||
replace: options?.replace,
|
||||
})
|
||||
}
|
||||
|
||||
// Push the debounced keyword to the URL (reset page to 0 when search changes)
|
||||
useEffect(() => {
|
||||
if (debouncedKeyword !== keyword) {
|
||||
updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true })
|
||||
}
|
||||
}, [debouncedKeyword])
|
||||
|
||||
// Sync keywordInput when navigating back via returnTo
|
||||
useEffect(() => {
|
||||
setKeywordInput(keyword)
|
||||
}, [keyword])
|
||||
|
||||
const { data: skillPage, isLoading } = useMySkills({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
filter: filter === 'ALL' ? undefined : filter,
|
||||
q: keyword || undefined,
|
||||
namespace: namespaceFilter || undefined,
|
||||
})
|
||||
const { data: namespacesPage } = useMyNamespaces({ page: 0, size: 100 })
|
||||
const namespaceOptions = namespacesPage?.items ?? []
|
||||
|
||||
const skills = skillPage?.items ?? []
|
||||
const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1
|
||||
const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN'))
|
||||
const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== ''
|
||||
const emptyStateKey = getMySkillEmptyStateKey(filter)
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
|
|
@ -57,10 +107,15 @@ export function MySkillsPage() {
|
|||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({
|
||||
to: `/space/${namespace}/${encodeURIComponent(slug)}`,
|
||||
search: { returnTo: '/dashboard/skills' },
|
||||
search: { returnTo: buildReturnTo(location) },
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setKeywordInput('')
|
||||
updateSearch({ q: undefined, namespace: undefined, page: 0 })
|
||||
}
|
||||
|
||||
const handleUpdateSkill = (namespace: string, visibility?: string) => {
|
||||
navigate({
|
||||
to: '/dashboard/publish',
|
||||
|
|
@ -238,21 +293,61 @@ export function MySkillsPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableFilters.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === option ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setFilter(option)
|
||||
setPage(0)
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
type="search"
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
placeholder={t('mySkills.searchPlaceholder')}
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
{t(`mySkills.filters.${option}`)}
|
||||
</Button>
|
||||
))}
|
||||
<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) => (
|
||||
<SelectItem key={ns.id} value={ns.slug}>
|
||||
@{ns.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasActiveSearch ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
{t('mySkills.clearSearch')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableFilters.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === option ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
updateSearch({ filter: option === 'ALL' ? undefined : option, page: 0 })
|
||||
}}
|
||||
>
|
||||
{t(`mySkills.filters.${option}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skillPage && skillPage.total > 0 ? (
|
||||
|
|
@ -400,17 +495,23 @@ export function MySkillsPage() {
|
|||
</div>
|
||||
|
||||
{skillPage.total > PAGE_SIZE ? (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={(next) => updateSearch({ page: next })} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t(emptyStateKey.title)}
|
||||
description={t(emptyStateKey.description)}
|
||||
title={hasActiveSearch ? t('mySkills.emptySearchTitle') : t(emptyStateKey.title)}
|
||||
description={hasActiveSearch ? t('mySkills.emptySearchDescription') : t(emptyStateKey.description)}
|
||||
action={
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
{t('mySkills.publishSkill')}
|
||||
</Button>
|
||||
hasActiveSearch ? (
|
||||
<Button size="lg" variant="outline" onClick={handleClearSearch}>
|
||||
{t('mySkills.clearSearch')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
{t('mySkills.publishSkill')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export function SearchPage() {
|
|||
}
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` })
|
||||
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, search: { returnTo: `${window.location.pathname}${window.location.search}` } })
|
||||
}
|
||||
|
||||
const filteredStarredSkills = starredOnly
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ export function SkillDetailPage() {
|
|||
&& ['PENDING_REVIEW', 'SCANNING', 'SCAN_FAILED'].includes(headlineVersion?.status ?? '')
|
||||
const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW'
|
||||
const hasRejectedOwnerPreview = ownerPreviewVersion?.status === 'REJECTED'
|
||||
const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false
|
||||
const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview)
|
||||
const canInteract = skill?.canInteract ?? true
|
||||
const canReport = skill?.canReport ?? true
|
||||
|
|
@ -762,7 +761,7 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.versionStatusPendingReview')}
|
||||
</span>
|
||||
)}
|
||||
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview || hasRejectedVersion) && skill.canManageLifecycle && (
|
||||
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
|
||||
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
|
||||
{t('skillDetail.rejectedBadge')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,43 @@ interface PaginationProps {
|
|||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
type PageItem = number | 'ellipsis'
|
||||
|
||||
/**
|
||||
* Builds the list of page slots to render. Always shows the first and last page,
|
||||
* the current page, and one neighbour on each side, collapsing the rest into
|
||||
* ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed.
|
||||
*/
|
||||
function buildPageItems(current: number, totalPages: number): PageItem[] {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i)
|
||||
}
|
||||
|
||||
const items: PageItem[] = []
|
||||
const first = 0
|
||||
const last = totalPages - 1
|
||||
const start = Math.max(first + 1, current - 1)
|
||||
const end = Math.min(last - 1, current + 1)
|
||||
|
||||
items.push(first)
|
||||
if (start > first + 1) {
|
||||
items.push('ellipsis')
|
||||
}
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
items.push(i)
|
||||
}
|
||||
if (end < last - 1) {
|
||||
items.push('ellipsis')
|
||||
}
|
||||
items.push(last)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const pageItems = buildPageItems(page, totalPages)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<Button
|
||||
|
|
@ -20,13 +55,34 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||
>
|
||||
{t('pagination.prev')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-secondary/40 text-sm font-medium text-foreground">
|
||||
<span className="text-muted-foreground">{t('pagination.pagePrefix')}</span>
|
||||
<span className="text-primary">{page + 1}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span>{totalPages}</span>
|
||||
{t('pagination.pageSuffix') && <span className="text-muted-foreground">{t('pagination.pageSuffix')}</span>}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{pageItems.map((item, index) =>
|
||||
item === 'ellipsis' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 text-sm text-muted-foreground select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={item}
|
||||
type="button"
|
||||
variant={item === page ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(item)}
|
||||
aria-label={t('pagination.goToPage', { page: item + 1 })}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
className="min-w-[2.25rem] h-9 px-2"
|
||||
>
|
||||
{item + 1}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
|
||||
import type { SkillSummary, PagedResponse } from '@/api/types'
|
||||
import { meApi, promotionApi, namespaceApi } from '@/api/client'
|
||||
|
||||
async function getMySkills(params: { page?: number; size?: number; filter?: string } = {}): Promise<PagedResponse<SkillSummary>> {
|
||||
async function getMySkills(params: { page?: number; size?: number; filter?: string; q?: string; namespace?: string } = {}): Promise<PagedResponse<SkillSummary>> {
|
||||
return meApi.getSkills(params)
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +31,11 @@ async function submitPromotion(params: { sourceSkillId: number; sourceVersionId:
|
|||
})
|
||||
}
|
||||
|
||||
export function useMySkills(params: { page?: number; size?: number; filter?: string } = {}) {
|
||||
export function useMySkills(params: { page?: number; size?: number; filter?: string; q?: string; namespace?: string } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', 'my', params],
|
||||
queryFn: () => getMySkills(params),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue