mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
feat(web): add Phase 2 frontend foundation - types, hooks, shared and feature components
This commit is contained in:
parent
1db4ec1e78
commit
a3fe76eaeb
23 changed files with 1513 additions and 12 deletions
|
|
@ -13,30 +13,34 @@
|
|||
"generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@tanstack/react-query": "^5.64.0",
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"openapi-typescript": "^7.6.1"
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
806
web/pnpm-lock.yaml
generated
806
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,3 +5,103 @@ export type OAuthProvider = components['schemas']['OAuthProvider']
|
|||
export type ApiToken = components['schemas']['ApiToken']
|
||||
export type CreateTokenRequest = components['schemas']['CreateTokenRequest']
|
||||
export type CreateTokenResponse = components['schemas']['CreateTokenResponse']
|
||||
|
||||
// Namespace types
|
||||
export interface Namespace {
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
description?: string
|
||||
type: 'GLOBAL' | 'TEAM'
|
||||
avatarUrl?: string
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NamespaceMember {
|
||||
id: number
|
||||
namespaceId: number
|
||||
userId: number
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Skill types
|
||||
export interface SkillSummary {
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
downloadCount: number
|
||||
starCount: number
|
||||
ratingAvg?: number
|
||||
ratingCount: number
|
||||
latestVersion?: string
|
||||
namespace: Namespace
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SkillDetail {
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
visibility: string
|
||||
status: string
|
||||
downloadCount: number
|
||||
starCount: number
|
||||
latestVersion?: string
|
||||
namespaceId: number
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
id: number
|
||||
version: string
|
||||
status: string
|
||||
changelog?: string
|
||||
fileCount: number
|
||||
totalSize: number
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
export interface SkillFile {
|
||||
id: number
|
||||
filePath: string
|
||||
fileSize: number
|
||||
contentType: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export interface SkillTag {
|
||||
id: number
|
||||
tagName: string
|
||||
versionId: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Search and pagination
|
||||
export interface SearchParams {
|
||||
q?: string
|
||||
namespace?: string
|
||||
sort?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// Publish
|
||||
export interface PublishResult {
|
||||
skillId: number
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
status: string
|
||||
fileCount: number
|
||||
totalSize: number
|
||||
}
|
||||
|
|
|
|||
19
web/src/features/namespace/use-my-namespaces.ts
Normal file
19
web/src/features/namespace/use-my-namespaces.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Namespace } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useMyNamespaces() {
|
||||
return useQuery({
|
||||
queryKey: ['my-namespaces'],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/namespaces' as any, {})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as Namespace[]
|
||||
},
|
||||
})
|
||||
}
|
||||
26
web/src/features/namespace/use-namespace-detail.ts
Normal file
26
web/src/features/namespace/use-namespace-detail.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Namespace } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useNamespaceDetail(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['namespace', slug],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/namespaces/{slug}' as any, {
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as Namespace
|
||||
},
|
||||
enabled: !!slug,
|
||||
})
|
||||
}
|
||||
26
web/src/features/namespace/use-namespace-members.ts
Normal file
26
web/src/features/namespace/use-namespace-members.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { NamespaceMember } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useNamespaceMembers(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['namespace-members', slug],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/namespaces/{slug}/members' as any, {
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as NamespaceMember[]
|
||||
},
|
||||
enabled: !!slug,
|
||||
})
|
||||
}
|
||||
65
web/src/features/publish/upload-zone.tsx
Normal file
65
web/src/features/publish/upload-zone.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface UploadZoneProps {
|
||||
onFileSelect: (file: File) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onFileSelect(acceptedFiles[0])
|
||||
}
|
||||
},
|
||||
[onFileSelect]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/zip': ['.zip'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
disabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
|
||||
isDragActive && 'border-primary bg-primary/5',
|
||||
!isDragActive && 'border-muted-foreground/25 hover:border-primary/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
className="w-12 h-12 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-muted-foreground">放开以上传文件...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">拖拽 ZIP 文件到此处,或点击选择</p>
|
||||
<p className="text-xs text-muted-foreground">仅支持 .zip 格式</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
web/src/features/publish/use-publish-skill.ts
Normal file
46
web/src/features/publish/use-publish-skill.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { PublishResult } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
interface PublishSkillParams {
|
||||
namespace: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export function usePublishSkill() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ namespace, file }: PublishSkillParams) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const csrfToken = getCsrfToken()
|
||||
const headers: HeadersInit = {
|
||||
...(csrfToken && { 'X-XSRF-TOKEN': csrfToken }),
|
||||
}
|
||||
|
||||
const { data, error, response } = await client.POST('/api/v1/skills/{namespace}/publish' as any, {
|
||||
params: {
|
||||
path: {
|
||||
namespace,
|
||||
},
|
||||
},
|
||||
body: formData as any,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return data as unknown as PublishResult
|
||||
},
|
||||
})
|
||||
}
|
||||
33
web/src/features/search/search-bar.tsx
Normal file
33
web/src/features/search/search-bar.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface SearchBarProps {
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
onSearch?: (query: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', onSearch }: SearchBarProps) {
|
||||
const [query, setQuery] = useState(defaultValue)
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (onSearch) {
|
||||
onSearch(query)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit">搜索</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
36
web/src/features/skill/file-tree.tsx
Normal file
36
web/src/features/skill/file-tree.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { SkillFile } from '@/api/types'
|
||||
|
||||
interface FileTreeProps {
|
||||
files: SkillFile[]
|
||||
onFileClick?: (file: SkillFile) => void
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function FileTree({ files, onFileClick }: FileTreeProps) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted px-4 py-2 text-sm font-medium">
|
||||
文件列表 ({files.length})
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="px-4 py-2 hover:bg-muted/50 cursor-pointer flex items-center justify-between"
|
||||
onClick={() => onFileClick?.(file)}
|
||||
>
|
||||
<span className="text-sm font-mono">{file.filePath}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
web/src/features/skill/install-command.tsx
Normal file
20
web/src/features/skill/install-command.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { CopyButton } from '@/shared/components/copy-button'
|
||||
|
||||
interface InstallCommandProps {
|
||||
namespace: string
|
||||
slug: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export function InstallCommand({ namespace, slug, version }: InstallCommandProps) {
|
||||
const command = version
|
||||
? `skillhub install ${namespace}/${slug}@${version}`
|
||||
: `skillhub install ${namespace}/${slug}`
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
|
||||
<code className="flex-1 text-sm font-mono">{command}</code>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
web/src/features/skill/markdown-renderer.tsx
Normal file
24
web/src/features/skill/markdown-renderer.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
// @ts-ignore - react-markdown types issue
|
||||
div: ({ node, ...props }) => <div className="prose prose-sm dark:prose-invert max-w-none" {...props} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
web/src/features/skill/skill-card.tsx
Normal file
38
web/src/features/skill/skill-card.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { SkillSummary } from '@/api/types'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillSummary
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, onClick }: SkillCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className="p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{skill.displayName}</h3>
|
||||
<NamespaceBadge type={skill.namespace.type} name={skill.namespace.displayName} />
|
||||
</div>
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span>v{skill.latestVersion}</span>
|
||||
)}
|
||||
<span>{skill.downloadCount} 下载</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span>⭐ {skill.ratingAvg.toFixed(1)} ({skill.ratingCount})</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
29
web/src/features/skill/use-search-skills.ts
Normal file
29
web/src/features/skill/use-search-skills.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { SearchParams, PagedResponse, SkillSummary } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useSearchSkills(params: SearchParams) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', params],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/skills' as any, {
|
||||
params: {
|
||||
query: {
|
||||
q: params.q,
|
||||
namespace: params.namespace,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as PagedResponse<SkillSummary>
|
||||
},
|
||||
})
|
||||
}
|
||||
27
web/src/features/skill/use-skill-detail.ts
Normal file
27
web/src/features/skill/use-skill-detail.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { SkillDetail } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useSkillDetail(namespace: string, slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['skill', namespace, slug],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/skills/{namespace}/{slug}' as any, {
|
||||
params: {
|
||||
path: {
|
||||
namespace,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as SkillDetail
|
||||
},
|
||||
enabled: !!namespace && !!slug,
|
||||
})
|
||||
}
|
||||
28
web/src/features/skill/use-skill-files.ts
Normal file
28
web/src/features/skill/use-skill-files.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { SkillFile } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useSkillFiles(namespace: string, slug: string, version: string) {
|
||||
return useQuery({
|
||||
queryKey: ['skill-files', namespace, slug, version],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/skills/{namespace}/{slug}/versions/{version}/files' as any, {
|
||||
params: {
|
||||
path: {
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as SkillFile[]
|
||||
},
|
||||
enabled: !!namespace && !!slug && !!version,
|
||||
})
|
||||
}
|
||||
27
web/src/features/skill/use-skill-versions.ts
Normal file
27
web/src/features/skill/use-skill-versions.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { SkillVersion } from '@/api/types'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from '@/api/generated/schema'
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
|
||||
export function useSkillVersions(namespace: string, slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['skill-versions', namespace, slug],
|
||||
queryFn: async () => {
|
||||
const { data, error, response } = await client.GET('/api/v1/skills/{namespace}/{slug}/versions' as any, {
|
||||
params: {
|
||||
path: {
|
||||
namespace,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return data as unknown as SkillVersion[]
|
||||
},
|
||||
enabled: !!namespace && !!slug,
|
||||
})
|
||||
}
|
||||
32
web/src/shared/components/copy-button.tsx
Normal file
32
web/src/shared/components/copy-button.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useState } from 'react'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CopyButton({ text, className }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
19
web/src/shared/components/empty-state.tsx
Normal file
19
web/src/shared/components/empty-state.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
web/src/shared/components/namespace-badge.tsx
Normal file
23
web/src/shared/components/namespace-badge.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface NamespaceBadgeProps {
|
||||
type: 'GLOBAL' | 'TEAM'
|
||||
name: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NamespaceBadge({ type, name, className }: NamespaceBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
type === 'GLOBAL'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
33
web/src/shared/components/pagination.tsx
Normal file
33
web/src/shared/components/pagination.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
web/src/shared/components/skeleton-loader.tsx
Normal file
28
web/src/shared/components/skeleton-loader.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-5/6"></div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonListProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 6 }: SkeletonListProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
web/src/shared/hooks/use-debounce.ts
Normal file
12
web/src/shared/hooks/use-debounce.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue