From b70d4336b0b92a43975d7aa773cbf9acfc31d37c Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 14:29:07 +0800 Subject: [PATCH 1/9] fix: hide hidden skills from regular viewers --- .../domain/skill/VisibilityChecker.java | 3 ++ .../domain/skill/VisibilityCheckerTest.java | 29 +++++++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 29 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index a57ba4e7..16980af1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -7,6 +7,9 @@ import java.util.Map; public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { + if (skill.isHidden()) { + return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + } if (skill.getLatestVersionId() == null) { return isOwner(skill, currentUserId); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 46d8601b..1ffe5c8f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -15,6 +15,7 @@ class VisibilityCheckerTest { private Skill namespaceOnlySkill; private Skill privateSkill; private Skill unpublishedPublicSkill; + private Skill hiddenPublicSkill; private static final Long NAMESPACE_ID = 1L; private static final String OWNER_ID = "user-100"; @@ -33,6 +34,9 @@ class VisibilityCheckerTest { privateSkill = new Skill(NAMESPACE_ID, "private-skill", OWNER_ID, SkillVisibility.PRIVATE); privateSkill.setLatestVersionId(12L); unpublishedPublicSkill = new Skill(NAMESPACE_ID, "draft-public-skill", OWNER_ID, SkillVisibility.PUBLIC); + hiddenPublicSkill = new Skill(NAMESPACE_ID, "hidden-public-skill", OWNER_ID, SkillVisibility.PUBLIC); + hiddenPublicSkill.setLatestVersionId(13L); + hiddenPublicSkill.setHidden(true); } @Test @@ -129,4 +133,29 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(unpublishedPublicSkill, OWNER_ID, Map.of()); assertTrue(canAccess); } + + @Test + void testHiddenSkillNotAccessibleByAnonymous() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, null, Map.of()); + assertFalse(canAccess); + } + + @Test + void testHiddenSkillNotAccessibleByOtherUser() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of()); + assertFalse(canAccess); + } + + @Test + void testHiddenSkillAccessibleByOwner() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OWNER_ID, Map.of()); + assertTrue(canAccess); + } + + @Test + void testHiddenSkillAccessibleByNamespaceAdmin() { + Map roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN); + boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles); + assertTrue(canAccess); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 37e43fb9..4b56ff9b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -257,6 +257,35 @@ class SkillQueryServiceTest { assertEquals("own-skill", result.getContent().get(0).getSlug()); } + @Test + void testListSkillsByNamespace_ShouldHideHiddenSkillsFromRegularUsers() throws Exception { + String namespaceSlug = "test-ns"; + String userId = "user-100"; + Map userNsRoles = Map.of(); + Pageable pageable = PageRequest.of(0, 10); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill visibleSkill = new Skill(1L, "visible-skill", "user-200", SkillVisibility.PUBLIC); + setId(visibleSkill, 1L); + visibleSkill.setLatestVersionId(11L); + Skill hiddenSkill = new Skill(1L, "hidden-skill", "user-300", SkillVisibility.PUBLIC); + setId(hiddenSkill, 2L); + hiddenSkill.setLatestVersionId(12L); + hiddenSkill.setHidden(true); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) + .thenReturn(List.of(visibleSkill, hiddenSkill)); + when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true); + when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false); + + Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals("visible-skill", result.getContent().get(0).getSlug()); + } + @Test void testListFiles() throws Exception { // Arrange From beff78512a7e645f3cd21335c986ddf862008b76 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 14:53:26 +0800 Subject: [PATCH 2/9] fix: avoid dashboard preview crash after registration --- web/src/pages/dashboard-preview.test.ts | 8 ++++++++ web/src/pages/dashboard-preview.ts | 7 ++++--- web/src/pages/dashboard.tsx | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/web/src/pages/dashboard-preview.test.ts b/web/src/pages/dashboard-preview.test.ts index 0624595f..4b71e656 100644 --- a/web/src/pages/dashboard-preview.test.ts +++ b/web/src/pages/dashboard-preview.test.ts @@ -25,4 +25,12 @@ describe('limitPreviewItems', () => { remainingCount: 1, }) }) + + it('returns an empty preview instead of throwing when input is not an array', () => { + expect(limitPreviewItems({ items: ['a', 'b'] } as never, 3)).toEqual({ + items: [], + hasMore: false, + remainingCount: 0, + }) + }) }) diff --git a/web/src/pages/dashboard-preview.ts b/web/src/pages/dashboard-preview.ts index 66fd18c9..8cf9c8e2 100644 --- a/web/src/pages/dashboard-preview.ts +++ b/web/src/pages/dashboard-preview.ts @@ -1,10 +1,11 @@ -export function limitPreviewItems(items: T[], limit: number): { +export function limitPreviewItems(items: T[] | null | undefined | unknown, limit: number): { items: T[] hasMore: boolean remainingCount: number } { - const visibleItems = items.slice(0, limit) - const remainingCount = Math.max(items.length - visibleItems.length, 0) + const normalizedItems: T[] = Array.isArray(items) ? (items as T[]) : [] + const visibleItems = normalizedItems.slice(0, limit) + const remainingCount = Math.max(normalizedItems.length - visibleItems.length, 0) return { items: visibleItems, diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 082e378b..896ec209 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,6 +1,7 @@ import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' +import type { SkillSummary } from '@/api/types' import { useMySkills } from '@/shared/hooks/use-skill-queries' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -14,7 +15,7 @@ export function DashboardPage() { const { user, hasRole } = useAuth() const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const { data: skillPage, isLoading: isLoadingSkills } = useMySkills({ page: 0, size: skillPreviewPageSize }) - const skillPreview = limitPreviewItems(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT) + const skillPreview = limitPreviewItems(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT) return (
From b5bbb09c73752b525100cc6d59d2375ccab85a3d Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 15:04:46 +0800 Subject: [PATCH 3/9] fix: restrict skill hiding to super admins --- .../admin/AdminSkillController.java | 4 +- .../admin/AdminSkillReportController.java | 11 ++++-- .../service/SkillSearchAppService.java | 7 +++- .../admin/AdminSkillControllerTest.java | 18 ++++++++- .../admin/AdminSkillReportControllerTest.java | 11 ++++++ .../service/SkillSearchAppServiceTest.java | 37 ++++++++++++++++++- web/src/pages/skill-detail.tsx | 21 ++++++----- 7 files changed, 91 insertions(+), 18 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index 25e3d053..708cfdb8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -29,7 +29,7 @@ public class AdminSkillController extends BaseApiController { } @PostMapping("/{skillId}/hide") - @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + @PreAuthorize("hasRole('SUPER_ADMIN')") public ApiResponse hideSkill(@PathVariable Long skillId, @RequestBody(required = false) AdminSkillActionRequest request, @AuthenticationPrincipal PlatformPrincipal principal, @@ -45,7 +45,7 @@ public class AdminSkillController extends BaseApiController { } @PostMapping("/{skillId}/unhide") - @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + @PreAuthorize("hasRole('SUPER_ADMIN')") public ApiResponse unhideSkill(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java index 0442a543..232ed8c0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.report.SkillReportDisposition; import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.AdminSkillReportActionRequest; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; import com.iflytek.skillhub.dto.ApiResponse; @@ -52,12 +53,16 @@ public class AdminSkillReportController extends BaseApiController { @RequestBody(required = false) AdminSkillReportActionRequest request, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { + SkillReportDisposition disposition = request != null && request.disposition() != null + ? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase()) + : SkillReportDisposition.RESOLVE_ONLY; + if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE && !principal.platformRoles().contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } var report = skillReportService.resolveReport( reportId, principal.userId(), - request != null && request.disposition() != null - ? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase()) - : SkillReportDisposition.RESOLVE_ONLY, + disposition, request != null ? request.comment() : null, httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 1e5da1c5..4a7cbf32 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -31,18 +32,21 @@ public class SkillSearchAppService { private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; private final NamespaceService namespaceService; + private final VisibilityChecker visibilityChecker; public SkillSearchAppService( SearchQueryService searchQueryService, SkillRepository skillRepository, NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, - NamespaceService namespaceService) { + NamespaceService namespaceService, + VisibilityChecker visibilityChecker) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.namespaceService = namespaceService; + this.visibilityChecker = visibilityChecker; } public record SearchResponse( @@ -171,6 +175,7 @@ public class SkillSearchAppService { return skillIds.stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) + .filter(skill -> visibilityChecker.canAccess(skill, userId, userNsRoles != null ? userNsRoles : Map.of())) .filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles)) .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) .toList(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java index 8901d105..2a6215b3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -51,8 +51,8 @@ class AdminSkillControllerTest { given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) .willReturn(skill); - PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); - var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); mockMvc.perform(post("/api/v1/admin/skills/10/hide") .with(authentication(auth)) @@ -100,4 +100,18 @@ class AdminSkillControllerTest { .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(403)); } + + @Test + void hideSkill_withSkillAdminRole_returns403() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java index 1f3cf509..cb2d74dd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -112,6 +112,17 @@ class AdminSkillReportControllerTest { .andExpect(jsonPath("$.data.status").value("RESOLVED")); } + @Test + void resolveReport_withHideDispositionAndSkillAdmin_returns403() throws Exception { + mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") + .with(authentication(adminAuth())) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + @Test void listReports_withAuditorRole_returns403() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 755e04b1..ef6b617d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.search.SearchQueryService; @@ -47,7 +48,14 @@ class SkillSearchAppServiceTest { @BeforeEach void setUp() { - service = new SkillSearchAppService(searchQueryService, skillRepository, namespaceRepository, skillVersionRepository, namespaceService); + service = new SkillSearchAppService( + searchQueryService, + skillRepository, + namespaceRepository, + skillVersionRepository, + namespaceService, + new VisibilityChecker() + ); } @Test @@ -114,6 +122,33 @@ class SkillSearchAppServiceTest { ); } + @Test + void search_shouldExcludeHiddenSkillsForRegularUsers() { + Skill visibleSkill = new Skill(1L, "visible-skill", "owner-1", SkillVisibility.PUBLIC); + setField(visibleSkill, "id", 10L); + visibleSkill.setLatestVersionId(101L); + + Skill hiddenSkill = new Skill(1L, "hidden-skill", "owner-2", SkillVisibility.PUBLIC); + setField(hiddenSkill, "id", 11L); + hiddenSkill.setLatestVersionId(102L); + hiddenSkill.setHidden(true); + + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(visibleSkill, hiddenSkill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + + SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of()); + + assertEquals(1, response.items().size()); + assertEquals("visible-skill", response.items().getFirst().slug()); + assertEquals(1, response.total()); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index db350b69..9d1582d7 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -107,6 +107,7 @@ export function SkillDetailPage() { const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined, diffSourceDocumentationPath) const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined, diffCompareDocumentationPath) const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const canHideSkill = hasRole('SUPER_ADMIN') const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW' const canInteract = skill?.canInteract ?? true const canReport = skill?.canReport ?? true @@ -736,15 +737,17 @@ export function SkillDetailPage() {
{t('skillDetail.governance')}
- {!skill.hidden ? ( - - ) : ( - - )} + {canHideSkill ? ( + !skill.hidden ? ( + + ) : ( + + ) + ) : null} {selectedVersionEntry && ( - ) -} - -function CodeBlock({ code }: { code: string }) { - return ( -
-
- bash - -
-
-        {code}
-      
-
- ) -} - -function QuickStartSection() { - const { t } = useTranslation() - const baseUrl = useMemo(() => getAppBaseUrl(), []) - - const steps = [ - { - icon: , - title: t('home.quickStart.steps.configureEnv.title'), - description: t('home.quickStart.steps.configureEnv.description'), - code: `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} - -# Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'`, - }, - { - icon: , - title: t('home.quickStart.steps.installSkills.title'), - description: t('home.quickStart.steps.installSkills.description'), - code: t('home.quickStart.steps.installSkills.code'), - }, - { - icon: , - title: t('home.quickStart.steps.publishSkills.title'), - description: t('home.quickStart.steps.publishSkills.description'), - code: t('home.quickStart.steps.publishSkills.code'), - }, - ] - - return ( -
-
-

- {t('home.quickStart.title')} - - {t('home.quickStart.subtitle')} - -

-

- {t('home.quickStart.description')} -

-
- -
- {steps.map((step, idx) => ( -
-
-
- {step.icon} -
-
-
-

- {step.title} -

-

- {step.description} -

-
- -
-
-
- ))} -
- -
-
- - {t('home.quickStart.tip')} - -
-
-
- ) -} export function HomePage() { const { t } = useTranslation() @@ -172,13 +35,13 @@ export function HomePage() { {/* Hero Section */}
-

+

SkillHub

-

+

{t('home.subtitle')}

-

+

{t('home.description')}

@@ -188,12 +51,19 @@ export function HomePage() {
- - + +
@@ -201,8 +71,10 @@ export function HomePage() {
-

{t('home.popularTitle')}

-

{t('home.popularDescription')}

+

+ {t('home.popularTitle')} +

+

{t('home.popularDescription')}

) } diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index adc5b89f..8dca53fb 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -1,444 +1,174 @@ import { Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { SearchBar } from '@/features/search/search-bar' -import { useAuth } from '@/features/auth/use-auth' -import { LanguageSwitcher } from '@/shared/components/language-switcher' import { normalizeSearchQuery } from '@/shared/lib/search-query' -import { UserMenu } from '@/shared/components/user-menu' -import { Button } from '@/shared/ui/button' -import { Check, Copy, Terminal, Settings, PackageOpen } from 'lucide-react' -import { useEffect, useRef, useState, useMemo } from 'react' - -function getAppBaseUrl(): string { - if (typeof window === 'undefined') { - return 'https://skill.xfyun.cn' - } - const runtimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__ - if (runtimeConfig?.appBaseUrl) { - return runtimeConfig.appBaseUrl - } - return `${window.location.protocol}//${window.location.host}` -} - -function CopyButton({ text }: { text: string }) { - const { t } = useTranslation() - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy:', err) - } - } - - return ( - - ) -} - -function CodeBlock({ code }: { code: string }) { - return ( -
-
- bash - -
-
-        {code}
-      
-
- ) -} - -function QuickStartSection() { - const { t } = useTranslation() - const baseUrl = useMemo(() => getAppBaseUrl(), []) - - const steps = [ - { - icon: , - title: t('landing.quickStart.steps.configureEnv.title'), - description: t('landing.quickStart.steps.configureEnv.description'), - code: `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} - -# Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'`, - }, - { - icon: , - title: t('landing.quickStart.steps.installSkills.title'), - description: t('landing.quickStart.steps.installSkills.description'), - code: t('landing.quickStart.steps.installSkills.code'), - }, - { - icon: , - title: t('landing.quickStart.steps.publishSkills.title'), - description: t('landing.quickStart.steps.publishSkills.description'), - code: t('landing.quickStart.steps.publishSkills.code'), - }, - ] - - return ( -
-
-

- {t('landing.quickStart.title')} - - {t('landing.quickStart.subtitle')} - -

-

- {t('landing.quickStart.description')} -

-
- -
- {steps.map((step, idx) => ( -
-
-
- {step.icon} -
-
-
-

- {step.title} -

-

- {step.description} -

-
- -
-
-
- ))} -
- -
-
- - {t('landing.quickStart.tip')} - -
-
-
- ) -} +import { PackageOpen, Terminal, Shield, Users, GitBranch, Search as SearchIcon, Settings } from 'lucide-react' +import { QuickStartSection } from '@/shared/components/quick-start' export function LandingPage() { const { t } = useTranslation() const navigate = useNavigate() - const { user, isLoading } = useAuth() - const canvasRef = useRef(null) - const [stats] = useState({ - skills: '1000+', - downloads: '50K+', - teams: '200+', - }) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const ctx = canvas.getContext('2d') - if (!ctx) return - - const resize = () => { - if (canvas) { - canvas.width = window.innerWidth - canvas.height = window.innerHeight - } - } - resize() - window.addEventListener('resize', resize) - - class Particle { - x: number - y: number - vx: number - vy: number - radius: number - - constructor(canvasWidth: number, canvasHeight: number) { - this.x = Math.random() * canvasWidth - this.y = Math.random() * canvasHeight - this.vx = (Math.random() - 0.5) * 0.3 - this.vy = (Math.random() - 0.5) * 0.3 - this.radius = Math.random() * 1.5 + 0.5 - } - - update(canvasWidth: number, canvasHeight: number) { - this.x += this.vx - this.y += this.vy - if (this.x < 0 || this.x > canvasWidth) this.vx *= -1 - if (this.y < 0 || this.y > canvasHeight) this.vy *= -1 - } - - draw(context: CanvasRenderingContext2D) { - context.beginPath() - context.arc(this.x, this.y, this.radius, 0, Math.PI * 2) - context.fillStyle = 'rgba(56, 189, 248, 0.6)' - context.fill() - } - } - - const particles: Particle[] = [] - const particleCount = 80 - - for (let i = 0; i < particleCount; i++) { - particles.push(new Particle(canvas.width, canvas.height)) - } - - const connectParticles = () => { - for (let i = 0; i < particles.length; i++) { - for (let j = i + 1; j < particles.length; j++) { - const dx = particles[i].x - particles[j].x - const dy = particles[i].y - particles[j].y - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance < 120) { - ctx.beginPath() - const opacity = 0.15 * (1 - distance / 120) - ctx.strokeStyle = 'rgba(56, 189, 248, ' + opacity + ')' - ctx.lineWidth = 0.5 - ctx.moveTo(particles[i].x, particles[i].y) - ctx.lineTo(particles[j].x, particles[j].y) - ctx.stroke() - } - } - } - } - - const animate = () => { - if (!canvas) return - ctx.clearRect(0, 0, canvas.width, canvas.height) - - particles.forEach(particle => { - particle.update(canvas.width, canvas.height) - particle.draw(ctx) - }) - - connectParticles() - requestAnimationFrame(animate) - } - - animate() - - return () => { - window.removeEventListener('resize', resize) - } - }, []) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: normalizeSearchQuery(query), sort: 'relevance', page: 0, starredOnly: false } }) + const normalized = normalizeSearchQuery(query) + navigate({ + to: '/search', + search: { q: normalized, sort: 'relevance', page: 0, starredOnly: false }, + }) } const features = [ { - icon: '🔒', - title: t('landing.featuresList.privateDeploy.title'), - description: t('landing.featuresList.privateDeploy.description'), + icon: , + title: t('landing.features.secure.title'), + description: t('landing.features.secure.description'), }, { - icon: '📦', - title: t('landing.featuresList.versionControl.title'), - description: t('landing.featuresList.versionControl.description'), + icon: , + title: t('landing.features.community.title'), + description: t('landing.features.community.description'), }, { - icon: '🔍', - title: t('landing.featuresList.smartSearch.title'), - description: t('landing.featuresList.smartSearch.description'), + icon: , + title: t('landing.features.integration.title'), + description: t('landing.features.integration.description'), }, { - icon: '👥', - title: t('landing.featuresList.teamwork.title'), - description: t('landing.featuresList.teamwork.description'), + icon: , + title: t('landing.features.versionControl.title', { defaultValue: '版本控制' }), + description: t('landing.features.versionControl.description', { defaultValue: '完善的版本管理和发布流程,确保技能包的质量和可追溯性。' }), }, { - icon: '✅', - title: t('landing.featuresList.governance.title'), - description: t('landing.featuresList.governance.description'), + icon: , + title: t('landing.features.cli.title', { defaultValue: 'CLI 工具' }), + description: t('landing.features.cli.description', { defaultValue: '强大的命令行工具,支持快速发布、安装和管理技能包。' }), }, { - icon: '⚡', - title: t('landing.featuresList.cliFirst.title'), - description: t('landing.featuresList.cliFirst.description'), + icon: , + title: t('landing.features.governance.title', { defaultValue: '审核治理' }), + description: t('landing.features.governance.description', { defaultValue: '内置审核流程和权限管理,保障企业级技能质量。' }), }, ] + const stats = [ + { value: '1000+', label: t('landing.stats.skills', { defaultValue: '项目库' }) }, + { value: '50K+', label: t('landing.stats.downloads', { defaultValue: '下载量' }) }, + { value: '200+', label: t('landing.stats.teams', { defaultValue: '团队' }) }, + ] + return ( -
-
-
- - SkillHub + <> + {/* Hero Section */} +
+

+ SkillHub +

+

+ {t('landing.hero.title')} +

+

+ {t('landing.hero.subtitle')} +

+ + {/* Search box */} +
+
+ + { + if (e.key === 'Enter') { + handleSearch((e.target as HTMLInputElement).value) + } + }} + /> +
+
+ + {/* CTA buttons */} +
+ + {t('landing.hero.exploreSkills')} + + + {t('landing.hero.publishSkill', { defaultValue: '开始构建' })} -
-
- - -
-
- -
-
-
-
- {t('landing.badge')} -
- -
-

- - SkillHub + {/* Stats */} +
+ {stats.map((stat) => ( +
+ + {stat.value} + + + {stat.label} -

-

- {t('landing.tagline')} - {t('landing.taglineHighlight')} -

-

- {t('landing.description')} -

-
- -
-
-
-
- -
-
- -
- - -
- -
- {Object.entries(stats).map(([key, value]) => ( -
-
- {value} -
-
- {key === 'skills' && t('landing.statsSkills')} - {key === 'downloads' && t('landing.statsDownloads')} - {key === 'teams' && t('landing.statsTeams')} -
-
- ))} -
+ ))}
+ -
-
-

- {t('landing.whyTitle')} SkillHub + {/* Features Section */} +
+
+
+

+ {t('landing.whySkillHub.title', { defaultValue: '为什么选择 SkillHub' })}

-

- {t('landing.whyDescription')} +

+ {t('landing.whySkillHub.subtitle', { defaultValue: '专为企业打造的私有化 Agent 技能管理平台' })}

-
- {features.map((feature, idx) => ( +
+ {features.map((feature) => (
-
-
-
{feature.icon}
-

- {feature.title} -

-

- {feature.description} -

+
+ {feature.icon}
+

+ {feature.title} +

+

+ {feature.description} +

))}
+
- {/* Quick Start Section */} - - -

- -
-
-
-
{t('landing.footerLicense')}
- -
-
-
-
+ {/* Quick Start */} + + ) } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 9d1582d7..bbfce280 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -461,12 +461,12 @@ export function SkillDetailPage() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} {isPendingPreview && ( - + {t('skillDetail.pendingPreviewBadge')} )} diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx new file mode 100644 index 00000000..7509d3e2 --- /dev/null +++ b/web/src/shared/components/quick-start.tsx @@ -0,0 +1,217 @@ +import { useTranslation } from 'react-i18next' +import { Check, Copy, Settings, Download, Upload } from 'lucide-react' +import { useMemo, useState } from 'react' + +function getAppBaseUrl(): string { + if (typeof window === 'undefined') { + return 'https://skill.xfyun.cn' + } + const runtimeConfig = (window as any).__SKILLHUB_RUNTIME_CONFIG__ + if (runtimeConfig?.appBaseUrl) { + return runtimeConfig.appBaseUrl + } + return `${window.location.protocol}//${window.location.host}` +} + +function CopyButton({ text }: { text: string }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy:', err) + } + } + + return ( + + ) +} + +function CodeLine({ line }: { line: string }) { + if (line.startsWith('#')) { + return {line} + } + if (line.startsWith('export')) { + return ( + <> + export + {line.slice(6)} + + ) + } + if (line.startsWith('$env:')) { + const eqIdx = line.indexOf('=') + return ( + <> + {line.slice(0, eqIdx).trim()} + {` = ${line.slice(eqIdx + 1).trim()}`} + + ) + } + if (line.startsWith('clawhub')) { + return ( + <> + clawhub + {line.slice(7)} + + ) + } + return {line} +} + +interface CodeBlockProps { + icon: React.ReactNode + iconBg: string + iconColor: string + title: string + description: string + code: string +} + +function CodeBlock({ icon, iconBg, iconColor, title, description, code }: CodeBlockProps) { + return ( +
+
+
+
+ {icon} +
+
+
{title}
+
+ {description} +
+
+
+
+ + + + +
+
+
+ {code.split('\n').map((line, i) => ( +
+ +
+ ))} +
+
+ ) +} + +interface QuickStartProps { + /** 'landing' uses full-width section with centered title; 'page' uses inline layout */ + variant?: 'landing' | 'page' + /** i18n namespace prefix, e.g. 'landing' or 'home' */ + ns?: string +} + +export function QuickStartSection({ variant = 'page', ns = 'landing' }: QuickStartProps) { + const { t } = useTranslation() + const baseUrl = useMemo(() => getAppBaseUrl(), []) + + const envCode = `# Linux/macOS +export CLAWHUB_SITE=${baseUrl} +export CLAWHUB_REGISTRY=${baseUrl} + +# Windows PowerShell +$env:CLAWHUB_SITE = '${baseUrl}' +$env:CLAWHUB_REGISTRY = '${baseUrl}'` + + const installCode = t(`${ns}.quickStart.steps.installSkills.code`, { + defaultValue: '# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install ', + }) + + const publishCode = t(`${ns}.quickStart.steps.publishSkills.code`, { + defaultValue: '# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', + }) + + const steps: CodeBlockProps[] = [ + { + icon: , + iconBg: 'rgba(94,234,212,0.15)', + iconColor: 'var(--code-keyword, #5EEAD4)', + title: t(`${ns}.quickStart.steps.configureEnv.title`), + description: t(`${ns}.quickStart.steps.configureEnv.description`), + code: envCode, + }, + { + icon: , + iconBg: 'rgba(96,165,250,0.15)', + iconColor: '#60A5FA', + title: t(`${ns}.quickStart.steps.installSkills.title`), + description: t(`${ns}.quickStart.steps.installSkills.description`), + code: installCode, + }, + { + icon: , + iconBg: 'rgba(167,139,250,0.15)', + iconColor: '#A78BFA', + title: t(`${ns}.quickStart.steps.publishSkills.title`), + description: t(`${ns}.quickStart.steps.publishSkills.description`), + code: publishCode, + }, + ] + + if (variant === 'landing') { + return ( +
+
+
+

+ {t(`${ns}.quickStart.title`)} +

+

+ Quick Start +

+

+ {t(`${ns}.quickStart.description`, { defaultValue: t(`${ns}.quickStart.subtitle`) })} +

+
+
+ {steps.map((step, idx) => ( + + ))} +
+
+
+ ) + } + + return ( +
+
+

+ {t(`${ns}.quickStart.title`)} +

+

+ {t(`${ns}.quickStart.description`, { defaultValue: t(`${ns}.quickStart.subtitle`) })} +

+
+
+ {steps.map((step, idx) => ( + + ))} +
+
+ ) +} diff --git a/web/src/shared/components/skeleton-loader.tsx b/web/src/shared/components/skeleton-loader.tsx index caf475b2..c6ef3cd2 100644 --- a/web/src/shared/components/skeleton-loader.tsx +++ b/web/src/shared/components/skeleton-loader.tsx @@ -1,6 +1,6 @@ export function SkeletonCard() { return ( -
+
diff --git a/web/src/shared/ui/button.tsx b/web/src/shared/ui/button.tsx index 04701332..3f9d2d7f 100644 --- a/web/src/shared/ui/button.tsx +++ b/web/src/shared/ui/button.tsx @@ -8,7 +8,7 @@ const buttonVariants = cva( variants: { variant: { default: - 'bg-primary text-primary-foreground shadow-glow hover:brightness-110 hover:shadow-glow-lg active:scale-[0.98]', + 'bg-brand-gradient text-white shadow-sm hover:opacity-95 active:scale-[0.98]', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]', outline: diff --git a/web/src/shared/ui/card.tsx b/web/src/shared/ui/card.tsx index cc7e51ec..f434ce4f 100644 --- a/web/src/shared/ui/card.tsx +++ b/web/src/shared/ui/card.tsx @@ -6,9 +6,10 @@ const Card = React.forwardRef ) diff --git a/web/src/shared/ui/input.tsx b/web/src/shared/ui/input.tsx index 1e682d65..e5088693 100644 --- a/web/src/shared/ui/input.tsx +++ b/web/src/shared/ui/input.tsx @@ -4,14 +4,15 @@ import { cn } from '@/shared/lib/utils' export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, type, style, ...props }, ref) => { return ( diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index cea6202a..b4ca1853 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -39,9 +39,10 @@ export function TabsList({ children, className }: TabsListProps) { return (
{children}
@@ -65,12 +66,13 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) { type="button" onClick={() => context.setValue(value)} className={cn( - 'inline-flex items-center justify-center whitespace-nowrap rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap py-3 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', isActive - ? 'bg-card text-foreground shadow-sm border border-border/40' - : 'hover:text-foreground/80 hover:bg-secondary', + ? 'border-b-2 border-primary text-primary' + : 'text-muted-foreground hover:text-foreground/80', className )} + style={{ marginBottom: '-1px' }} > {children} diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 443df8d6..558fc033 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -6,9 +6,9 @@ const config: Config = { theme: { extend: { fontFamily: { - display: ['Playfair Display', 'Georgia', 'serif'], - heading: ['Outfit', 'DM Sans', 'system-ui', 'sans-serif'], - sans: ['DM Sans', 'system-ui', 'sans-serif'], + display: ['Inter', 'system-ui', 'sans-serif'], + heading: ['Inter', 'system-ui', 'sans-serif'], + sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], }, borderRadius: { From 4fa16a7f4a01a69e2126a660c1fb786c21a38b1f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 17 Mar 2026 17:07:28 +0800 Subject: [PATCH 9/9] feat: refine landing experience and restore my-skills metadata --- web/src/app/layout.tsx | 1 - web/src/i18n/locales/en.json | 24 ++++++++++++++++++++- web/src/i18n/locales/zh.json | 24 ++++++++++++++++++++- web/src/pages/dashboard/my-skills.tsx | 30 +++++++++++++++++++-------- web/src/pages/skill-detail.tsx | 8 +++---- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 20e2bf8c..85dcb1bf 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -19,7 +19,6 @@ export function Layout() { { label: t('nav.landing'), to: '/', exact: true }, { label: t('nav.home'), to: '/skills' }, { label: t('nav.search'), to: '/search' }, - { label: t('nav.skillDetail'), to: '/space/demo/example' }, { label: t('nav.dashboard'), to: '/dashboard', auth: true }, { label: t('nav.mySkills'), to: '/dashboard/skills', auth: true }, { label: t('nav.publish'), to: '/dashboard/publish', auth: true }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index edc527fb..283a8b94 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -15,7 +15,8 @@ "title": "Discover & Share AI Skills", "subtitle": "Build powerful AI agents with community-driven skills", "searchPlaceholder": "Search skills...", - "exploreSkills": "Explore Skills" + "exploreSkills": "Explore Skills", + "publishSkill": "Start Building" }, "features": { "secure": { @@ -29,8 +30,29 @@ "integration": { "title": "Easy Integration", "description": "Seamlessly integrate with your existing tools" + }, + "versionControl": { + "title": "Version Control", + "description": "Structured version management and release workflows that keep skill packages traceable and reliable." + }, + "cli": { + "title": "CLI Tooling", + "description": "Powerful command-line workflows for publishing, installing, and managing skill packages." + }, + "governance": { + "title": "Review Governance", + "description": "Built-in review flows and permission controls to keep enterprise skill quality high." } }, + "stats": { + "skills": "Catalogs", + "downloads": "Downloads", + "teams": "Teams" + }, + "whySkillHub": { + "title": "Why SkillHub", + "subtitle": "A private Agent skill platform designed for enterprise teams" + }, "badge": "Enterprise Skill Registry", "tagline": "Publish, Discover, Manage", "taglineHighlight": " Agent Skills", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 988e106c..bc7ec883 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -15,7 +15,8 @@ "title": "发现与分享 AI 技能", "subtitle": "使用社区驱动的技能构建强大的 AI 代理", "searchPlaceholder": "搜索技能...", - "exploreSkills": "探索技能" + "exploreSkills": "探索技能", + "publishSkill": "开始构建" }, "features": { "secure": { @@ -29,8 +30,29 @@ "integration": { "title": "轻松集成", "description": "无缝集成到您现有的工具中" + }, + "versionControl": { + "title": "版本控制", + "description": "完善的版本管理和发布流程,确保技能包的质量和可追溯性。" + }, + "cli": { + "title": "CLI 工具", + "description": "强大的命令行工具,支持快速发布、安装和管理技能包。" + }, + "governance": { + "title": "审核治理", + "description": "内置审核流程和权限管理,保障企业级技能质量。" } }, + "stats": { + "skills": "项目库", + "downloads": "下载量", + "teams": "团队" + }, + "whySkillHub": { + "title": "为什么选择 SkillHub", + "subtitle": "专为企业打造的私有化 Agent 技能管理平台" + }, "badge": "企业级技能注册中心", "tagline": "发布、发现、管理", "taglineHighlight": " Agent 技能包", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 08cd98f9..dcf79616 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -201,16 +201,28 @@ export function MySkillsPage() { {skill.summary && (

{skill.summary}

)} - {skill.status ? ( - - {resolveStatusLabel(skill.status)} +
+ @{skill.namespace} + {skill.latestVersion ? ( + v{skill.latestVersion} + ) : null} + + + + + {formatCompactCount(skill.downloadCount)} - ) : null} - {skill.latestVersionStatus ? ( - - {resolveStatusLabel(skill.latestVersionStatus)} - - ) : null} + {skill.status ? ( + + {resolveStatusLabel(skill.status)} + + ) : null} + {skill.latestVersionStatus ? ( + + {resolveStatusLabel(skill.latestVersionStatus)} + + ) : null} +
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? ( diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index bbfce280..09b1bb0e 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -445,9 +445,9 @@ export function SkillDetailPage() { } return ( -
+
{/* Main Content */} -
+