feat(web): add install method tabs

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-05 14:17:31 +08:00
parent 1ec93db0d6
commit 89cb8e2999
7 changed files with 125 additions and 8 deletions

View file

@ -11,6 +11,10 @@ function latestSeed(seed: PreparedSearchSeed) {
}
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
let seeded: PreparedSearchSeed | undefined
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
@ -40,7 +44,21 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
await expect(page).not.toHaveURL(/\/login\?returnTo=/)
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
await expect(page.getByText('Install', { exact: true })).toBeVisible()
await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
const clawhubTarget = current.skill.namespace === 'global'
? current.skill.slug
: `${current.skill.namespace}--${current.skill.slug}`
const skillhubNamespace = current.skill.namespace === 'global'
? ''
: ` --namespace ${current.skill.namespace}`
await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible()
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toBeVisible()
await page.getByRole('tab', { name: 'SkillHub CLI' }).click()
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByText(new RegExp(`npx @astron-team/skillhub@latest install ${escapeRegExp(current.skill.slug)}${escapeRegExp(skillhubNamespace)} --registry`))).toBeVisible()
await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
})
})

View file

@ -1,7 +1,13 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { InstallCommand, buildInstallCommand, buildInstallTarget, getBaseUrl } from './install-command'
import {
InstallCommand,
buildInstallCommand,
buildInstallTarget,
buildSkillhubInstallCommand,
getBaseUrl,
} from './install-command'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -62,6 +68,18 @@ describe('install-command', () => {
)
})
it('builds a one-line SkillHub npx command for the global namespace', () => {
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
)
})
it('builds a one-line SkillHub npx command with namespace for team skills', () => {
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe(
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn',
)
})
it('uses the runtime app base url when available', () => {
setMockWindow('https://app.example.com')
@ -92,4 +110,19 @@ describe('install-command', () => {
expect(html).toContain('leading-relaxed')
expect(html).toContain('break-all')
})
it('renders ClawHub CLI as the default install method', () => {
setMockWindow('https://app.example.com')
const html = renderToStaticMarkup(createElement(InstallCommand, {
namespace: 'team-alpha',
slug: 'meeting-minutes-generator',
}))
expect(html).toContain('skillDetail.installMethodClawhub')
expect(html).toContain('skillDetail.installMethodSkillhub')
expect(html).toContain('aria-selected="true"')
expect(html).toContain('npx clawhub install team-alpha--meeting-minutes-generator --registry https://app.example.com')
expect(html).not.toContain('npx @astron-team/skillhub@latest install meeting-minutes-generator --namespace team-alpha --registry https://app.example.com')
})
})

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/shared/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useCopyToClipboard } from '@/shared/lib/clipboard'
interface InstallCommandProps {
@ -33,14 +34,19 @@ export function buildInstallCommand(namespace: string, slug: string, baseUrl: st
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
}
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
}
interface CommandBlockProps {
command: string
}
function CommandBlock({ command }: CommandBlockProps) {
const { t } = useTranslation()
const [copied, copy] = useCopyToClipboard()
const baseUrl = useMemo(() => getBaseUrl(), [])
const command = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
const handleCopy = async () => {
try {
await copy(command)
@ -70,3 +76,29 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) {
</div>
)
}
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
const { t } = useTranslation()
const baseUrl = useMemo(() => getBaseUrl(), [])
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
return (
<Tabs defaultValue="clawhub" className="space-y-3">
<TabsList className="w-full gap-2 rounded-lg border bg-background/80 p-1 text-xs">
<TabsTrigger value="clawhub" className="flex-1 rounded-md px-3 py-2 text-xs">
{t('skillDetail.installMethodClawhub')}
</TabsTrigger>
<TabsTrigger value="skillhub" className="flex-1 rounded-md px-3 py-2 text-xs">
{t('skillDetail.installMethodSkillhub')}
</TabsTrigger>
</TabsList>
<TabsContent value="clawhub">
<CommandBlock command={clawhubCommand} />
</TabsContent>
<TabsContent value="skillhub">
<CommandBlock command={skillhubCommand} />
</TabsContent>
</Tabs>
)
}

View file

@ -801,6 +801,8 @@
"namespaceLabel": "Namespace",
"loginToRate": "Login to star and rate",
"install": "Install",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"download": "Download",
"labelsSectionTitle": "Labels",
"labelsSectionDescription": "Attach or remove recommended labels that help users filter and discover this skill.",

View file

@ -801,6 +801,8 @@
"namespaceLabel": "命名空间",
"loginToRate": "登录后可以收藏和评分",
"install": "安装",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"download": "下载",
"labelsSectionTitle": "标签管理",
"labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。",

View file

@ -1,3 +1,5 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
@ -28,4 +30,29 @@ describe('Tabs components', () => {
expect(typeof TabsContent).toBe('function')
expect(TabsContent.name).toBe('TabsContent')
})
it('renders semantic tablist, tab, and tabpanel roles', () => {
const html = renderToStaticMarkup(
createElement(Tabs, {
defaultValue: 'clawhub',
children: [
createElement(TabsList, {
key: 'list',
children: [
createElement(TabsTrigger, { key: 'clawhub', value: 'clawhub', children: 'ClawHub CLI' }),
createElement(TabsTrigger, { key: 'skillhub', value: 'skillhub', children: 'SkillHub CLI' }),
],
}),
createElement(TabsContent, { key: 'clawhub-content', value: 'clawhub', children: 'clawhub command' }),
createElement(TabsContent, { key: 'skillhub-content', value: 'skillhub', children: 'skillhub command' }),
],
}),
)
expect(html).toContain('role="tablist"')
expect(html).toContain('role="tab"')
expect(html).toContain('aria-selected="true"')
expect(html).toContain('aria-selected="false"')
expect(html).toContain('role="tabpanel"')
})
})

View file

@ -43,6 +43,7 @@ interface TabsListProps {
export function TabsList({ children, className }: TabsListProps) {
return (
<div
role="tablist"
className={cn(
'inline-flex items-center gap-6 border-b text-sm',
className
@ -69,6 +70,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
return (
<button
type="button"
role="tab"
aria-selected={isActive}
onClick={() => context.setValue(value)}
data-state={isActive ? 'active' : 'inactive'}
className={cn(
@ -96,5 +99,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) {
if (context.value !== value) return null
return <div className={cn('animate-fade-in', className)}>{children}</div>
return <div role="tabpanel" className={cn('animate-fade-in', className)}>{children}</div>
}