diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 5d699a45..0948756e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -8,6 +8,7 @@ public record SkillSummaryResponse( String slug, String displayName, String summary, + String visibility, String status, Long downloadCount, Integer starCount, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index a8609fea..d9c97041 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -67,6 +67,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getVisibility().name(), skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), 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 820980bb..bffa778a 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 @@ -198,6 +198,7 @@ public class SkillSearchAppService { skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getVisibility().name(), skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 607e3d65..cb4c0fc8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -102,6 +102,7 @@ class ClawHubCompatControllerTest { "my-skill", "My Skill", "test summary", + "PUBLIC", "ACTIVE", 10L, 5, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java index a381a39d..27d825dc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -43,6 +43,7 @@ class ClawHubRegistryFacadeTest { "time-skill", "Time Skill", "summary", + "PUBLIC", "ACTIVE", 12L, 3, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index de29b4f7..af16f4dd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -63,6 +63,7 @@ class MeControllerTest { "copilot", "Copilot", "Assist with code review", + "PUBLIC", "ACTIVE", 12L, 3, diff --git a/web/e2e/my-skills-update-prefill.spec.ts b/web/e2e/my-skills-update-prefill.spec.ts new file mode 100644 index 00000000..8aaa9091 --- /dev/null +++ b/web/e2e/my-skills-update-prefill.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('My Skills Update Prefill (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens publish page with namespace and visibility prefilled from my skills card', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = `update-ui-${Date.now().toString(36)}` + await builder.publishSkill(namespace.slug, { name: skillName }) + + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + + const skillCard = page.locator('.group').filter({ + has: page.getByRole('heading', { name: skillName, exact: true }), + }).first() + await expect(skillCard).toBeVisible() + await skillCard.getByRole('button', { name: 'Update' }).click() + + await expect(page).toHaveURL(/\/dashboard\/publish/) + + const currentUrl = new URL(page.url()) + expect(currentUrl.searchParams.get('namespace')).toBe(namespace.slug) + expect(currentUrl.searchParams.get('visibility')).toBe('PUBLIC') + await expect(page.locator('#namespace')).toContainText(`@${namespace.slug}`) + await expect(page.locator('#visibility')).toContainText('Public') + } finally { + await builder.cleanup() + } + }) + + test('falls back to public visibility when publish search params are invalid', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + + await page.goto(`/dashboard/publish?namespace=${encodeURIComponent(namespace.slug)}&visibility=internal`) + await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() + await expect(page.locator('#namespace')).toContainText(`@${namespace.slug}`) + await expect(page.locator('#visibility')).toContainText('Public') + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d8714025..27fb3c1b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -468,6 +468,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/reports": { parameters: { query?: never; @@ -500,6 +532,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/archive": { parameters: { query?: never; @@ -3239,6 +3303,10 @@ export interface components { targetVersion: string; confirmWarnings?: boolean; }; + SubmitReviewRequest: { + version: string; + targetVisibility: string; + }; SkillReportSubmitRequest: { reason?: string; details?: string; @@ -3257,6 +3325,9 @@ export interface components { reportId?: number; status?: string; }; + ConfirmPublishRequest: { + version: string; + }; AdminSkillActionRequest: { reason?: string; }; @@ -3648,6 +3719,7 @@ export interface components { slug?: string; displayName?: string; summary?: string; + visibility?: string; status?: string; /** Format: int64 */ downloadCount?: number; @@ -5569,6 +5641,60 @@ export interface operations { }; }; }; + submitForReview: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + submitForReview_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; submitReport: { parameters: { query?: never; @@ -5623,6 +5749,60 @@ export interface operations { }; }; }; + confirmPublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + confirmPublish_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; archiveSkill: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2b2f9382..be4a9938 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -145,6 +145,7 @@ export interface SkillSummary { slug: string displayName: string summary?: string + visibility?: string status?: string downloadCount: number starCount: number diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 4095904d..73cb0ff3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -248,6 +248,10 @@ const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/publish', beforeLoad: requireAuth, + validateSearch: (search: Record): { namespace?: string; visibility?: string } => ({ + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, + visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined, + }), component: PublishPage, }) diff --git a/web/src/features/publish/publish-prefill.test.ts b/web/src/features/publish/publish-prefill.test.ts new file mode 100644 index 00000000..8152c711 --- /dev/null +++ b/web/src/features/publish/publish-prefill.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { normalizePublishPrefill } from './publish-prefill' + +describe('normalizePublishPrefill', () => { + it('keeps namespace and normalizes visibility for valid route search params', () => { + expect(normalizePublishPrefill({ + namespace: 'team-ai', + visibility: 'private', + })).toEqual({ + namespace: 'team-ai', + visibility: 'PRIVATE', + }) + }) + + it('falls back to PUBLIC when visibility is missing or invalid', () => { + expect(normalizePublishPrefill({ + namespace: 'team-ai', + visibility: 'internal', + })).toEqual({ + namespace: 'team-ai', + visibility: 'PUBLIC', + }) + }) + + it('trims namespace input from search params', () => { + expect(normalizePublishPrefill({ + namespace: ' team-ml ', + })).toEqual({ + namespace: 'team-ml', + visibility: 'PUBLIC', + }) + }) +}) diff --git a/web/src/features/publish/publish-prefill.ts b/web/src/features/publish/publish-prefill.ts new file mode 100644 index 00000000..629b1339 --- /dev/null +++ b/web/src/features/publish/publish-prefill.ts @@ -0,0 +1,23 @@ +const VALID_VISIBILITIES = new Set(['PUBLIC', 'NAMESPACE_ONLY', 'PRIVATE']) + +interface PublishPrefillSearch { + namespace?: string + visibility?: string +} + +export interface PublishPrefillState { + namespace: string + visibility: string +} + +export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPrefillState { + const namespace = typeof search.namespace === 'string' ? search.namespace.trim() : '' + const normalizedVisibility = typeof search.visibility === 'string' + ? search.visibility.trim().toUpperCase() + : '' + + return { + namespace, + visibility: VALID_VISIBILITIES.has(normalizedVisibility) ? normalizedVisibility : 'PUBLIC', + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c96495f9..5d51b7e3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -340,6 +340,7 @@ "HIDDEN": "Hidden" }, "publishNew": "Publish New Skill", + "update": "Update", "archive": "Archive", "unarchive": "Restore", "statusArchived": "Archived", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 12219285..42eed50a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -340,6 +340,7 @@ "HIDDEN": "已隐藏" }, "publishNew": "发布新技能", + "update": "更新", "archive": "归档", "unarchive": "恢复", "statusArchived": "已归档", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 17ffa2f4..f2c58112 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it, vi } from 'vitest' +import { createElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +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() vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => navigateMock, })) vi.mock('react-i18next', async () => { @@ -19,15 +25,25 @@ vi.mock('@/features/auth/use-auth', () => ({ })) vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, + Button: ({ + children, + onClick, + }: { + children?: ReactNode + onClick?: (event?: { stopPropagation: () => void }) => void + }) => { + const label = Array.isArray(children) ? children.join('') : String(children ?? '') + buttonRecords.push({ label, onClick }) + return createElement('button', null, children) + }, })) vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, + Card: ({ children }: { children: ReactNode }) => createElement('div', null, children), })) vi.mock('@/shared/components/empty-state', () => ({ - EmptyState: () => null, + EmptyState: () => createElement('div', null, 'empty-state'), })) vi.mock('@/shared/components/confirm-dialog', () => ({ @@ -35,7 +51,7 @@ vi.mock('@/shared/components/confirm-dialog', () => ({ })) vi.mock('@/shared/components/dashboard-page-header', () => ({ - DashboardPageHeader: () => null, + DashboardPageHeader: ({ actions }: { actions?: ReactNode }) => createElement('div', null, actions), })) vi.mock('@/shared/components/pagination', () => ({ @@ -49,16 +65,13 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ })) vi.mock('@/shared/hooks/use-user-queries', () => ({ - useMySkills: () => ({ - data: { items: [], total: 0, page: 0, size: 10 }, - isLoading: false, - }), + useMySkills: () => useMySkillsMock(), useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/shared/lib/skill-lifecycle', () => ({ - getHeadlineVersion: () => null, - getPublishedVersion: () => null, + getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), + getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getOwnerPreviewVersion: () => null, hasPendingOwnerPreview: () => false, })) @@ -79,7 +92,120 @@ vi.mock('@/api/client', () => ({ import { MySkillsPage } from './my-skills' +function findButton(label: string) { + const record = buttonRecords.find((item) => item.label === label) + if (!record) { + throw new Error(`Missing button: ${label}`) + } + return record +} + describe('MySkillsPage', () => { + beforeEach(() => { + navigateMock.mockReset() + buttonRecords.length = 0 + useMySkillsMock.mockReturnValue({ + data: { + items: [ + { + id: 1, + displayName: 'Team Agent', + summary: 'summary', + namespace: 'team-ai', + slug: 'team-agent', + downloadCount: 42, + status: 'PUBLISHED', + visibility: 'PRIVATE', + canSubmitPromotion: false, + }, + ], + total: 1, + page: 0, + size: 10, + }, + isLoading: false, + }) + }) + + it('navigates to publish page with namespace and visibility when update is clicked', () => { + renderToStaticMarkup(createElement(MySkillsPage)) + + const stopPropagation = vi.fn() + findButton('mySkills.update').onClick?.({ stopPropagation }) + + expect(stopPropagation).toHaveBeenCalledTimes(1) + expect(navigateMock).toHaveBeenCalledWith({ + to: '/dashboard/publish', + search: { + namespace: 'team-ai', + visibility: 'PRIVATE', + }, + }) + }) + + it('does not render update action for archived skills', () => { + useMySkillsMock.mockReturnValue({ + data: { + items: [ + { + id: 2, + displayName: 'Archived Agent', + summary: 'summary', + namespace: 'team-ai', + slug: 'archived-agent', + downloadCount: 7, + status: 'ARCHIVED', + visibility: 'PUBLIC', + canSubmitPromotion: false, + }, + ], + total: 1, + page: 0, + size: 10, + }, + isLoading: false, + }) + + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false) + }) + + it('falls back to public visibility when the skill card data has no visibility field', () => { + useMySkillsMock.mockReturnValue({ + data: { + items: [ + { + id: 3, + displayName: 'Default Visibility Agent', + summary: 'summary', + namespace: 'team-ai', + slug: 'default-visibility-agent', + downloadCount: 9, + status: 'PUBLISHED', + canSubmitPromotion: false, + }, + ], + total: 1, + page: 0, + size: 10, + }, + isLoading: false, + }) + + renderToStaticMarkup(createElement(MySkillsPage)) + + findButton('mySkills.update').onClick?.({ stopPropagation: vi.fn() }) + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/dashboard/publish', + search: { + namespace: 'team-ai', + visibility: 'PUBLIC', + }, + }) + }) + it('exports a named component function', () => { expect(typeof MySkillsPage).toBe('function') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 5f437cd7..78192c32 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -61,6 +61,13 @@ export function MySkillsPage() { }) } + const handleUpdateSkill = (namespace: string, visibility?: string) => { + navigate({ + to: '/dashboard/publish', + search: { namespace, visibility: visibility || 'PUBLIC' }, + }) + } + const resolveStatusLabel = (status?: string) => { if (status === 'HIDDEN') { return t('mySkills.statusHidden') @@ -306,6 +313,18 @@ export function MySkillsPage() {
+ {skill.status !== 'ARCHIVED' && ( + + )} {hasPendingPreview && ownerPreviewVersion ? (