feat(web): add Phase 2 frontend foundation - types, hooks, shared and feature components

This commit is contained in:
vsxd 2026-03-12 02:43:01 +08:00
parent 1db4ec1e78
commit a3fe76eaeb
23 changed files with 1513 additions and 12 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
}

View 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[]
},
})
}

View 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,
})
}

View 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,
})
}

View 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>
)
}

View 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
},
})
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
},
})
}

View 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,
})
}

View 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,
})
}

View 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,
})
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}