Merge branch 'feature/improvement'

This commit is contained in:
vsxd 2026-03-13 14:49:23 +08:00
commit 0cb940e2f8
41 changed files with 1203 additions and 321 deletions

View file

@ -26,9 +26,10 @@ public class ReviewPermissionChecker {
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
// Cannot review own submission
// Admins can review their own submissions
if (task.getSubmittedBy().equals(userId)) {
return false;
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");
}
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);

View file

@ -16,13 +16,29 @@ class ReviewPermissionCheckerTest {
// --- canReview tests ---
@Test
void cannotReviewOwnSubmission() {
void regularUserCannotReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of()));
}
@Test
void skillAdminCanReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN")));
}
@Test
void superAdminCanReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SUPER_ADMIN")));
}
@Test
void teamAdminCanReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, "user-2");

View file

@ -78,7 +78,7 @@ export function Layout() {
<span className="text-xl font-bold font-heading text-foreground">SkillHub</span>
</div>
<p className="text-sm text-muted-foreground max-w-sm">
{t('layout.footerDescription')}
</p>
</div>

View file

@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { authApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import type { OAuthProvider } from '@/api/types'
@ -8,6 +9,7 @@ interface LoginButtonProps {
}
export function LoginButton({ returnTo }: LoginButtonProps) {
const { t } = useTranslation()
const { data, isLoading } = useQuery<OAuthProvider[]>({
queryKey: ['auth', 'providers', returnTo ?? ''],
queryFn: () => authApi.getProviders(returnTo),
@ -20,7 +22,7 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
<div className="space-y-3">
<Button className="w-full h-12" disabled>
<div className="w-5 h-5 rounded-full animate-shimmer mr-3" />
...
{t('loginButton.loading')}
</Button>
</div>
)
@ -40,7 +42,7 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
使 {provider.name}
{t('loginButton.loginWith', { name: provider.name })}
</Button>
))}
</div>

View file

@ -1,15 +1,25 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import type { LocalLoginRequest, LocalRegisterRequest } from '@/api/types'
import type { LocalLoginRequest, LocalRegisterRequest, User } from '@/api/types'
export function useLocalLogin() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request),
onSuccess: (user) => {
queryClient.setQueryData<User | null>(['auth', 'me'], user)
},
})
}
export function useLocalRegister() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request),
onSuccess: (user) => {
queryClient.setQueryData<User | null>(['auth', 'me'], user)
},
})
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import type { Namespace } from '@/api/types'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
@ -6,6 +7,7 @@ interface NamespaceHeaderProps {
}
export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
const { t } = useTranslation()
return (
<div className="flex items-start gap-4 p-6 border rounded-lg bg-card">
{namespace.avatarUrl && (
@ -18,7 +20,7 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{namespace.displayName}</h1>
<NamespaceBadge type={namespace.type} name={namespace.type === 'GLOBAL' ? '全局' : '团队'} />
<NamespaceBadge type={namespace.type} name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')} />
</div>
{namespace.description && (
<p className="text-muted-foreground">{namespace.description}</p>

View file

@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { cn } from '@/shared/lib/utils'
@ -8,6 +9,7 @@ interface UploadZoneProps {
}
export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
const { t } = useTranslation()
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
@ -57,11 +59,11 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
</svg>
</div>
{isDragActive ? (
<p className="text-sm text-primary font-medium">...</p>
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
) : (
<>
<p className="text-sm font-medium text-foreground"> ZIP </p>
<p className="text-xs text-muted-foreground"> .zip </p>
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
</>
)}
</div>

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
@ -8,7 +9,8 @@ interface SearchBarProps {
onSearch?: (query: string) => void
}
export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', onSearch }: SearchBarProps) {
export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBarProps) {
const { t } = useTranslation()
const [query, setQuery] = useState(defaultValue)
const handleSubmit = (e: React.FormEvent) => {
@ -38,12 +40,12 @@ export function SearchBar({ defaultValue = '', placeholder = '搜索技能...',
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
placeholder={placeholder || t('searchBar.placeholder')}
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
/>
</div>
<Button type="submit" size="lg" className="px-8">
{t('searchBar.button')}
</Button>
</form>
)

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import type { SkillFile } from '@/api/types'
interface FileTreeProps {
@ -12,10 +13,11 @@ function formatFileSize(bytes: number): string {
}
export function FileTree({ files, onFileClick }: FileTreeProps) {
const { t } = useTranslation()
return (
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted px-4 py-2 text-sm font-medium">
({files.length})
{t('fileTree.title', { count: files.length })}
</div>
<div className="divide-y">
{files.map((file) => (

View file

@ -1,4 +1,7 @@
import { CopyButton } from '@/shared/components/copy-button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/shared/ui/button'
interface InstallCommandProps {
namespace: string
@ -7,14 +10,40 @@ interface InstallCommandProps {
}
export function InstallCommand({ namespace, slug, version }: InstallCommandProps) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const command = version
? `skillhub install ${namespace}/${slug}@${version}`
: `skillhub install ${namespace}/${slug}`
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
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 className="relative overflow-hidden rounded-xl border border-border/60 bg-muted/50">
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleCopy}
title={copied ? t('copyButton.copied') : t('copyButton.copy')}
aria-label={copied ? t('copyButton.copied') : t('copyButton.copy')}
className="absolute right-2 top-2 z-10 h-8 w-8 rounded-md bg-background/80 backdrop-blur hover:bg-background"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<pre className="p-4 pr-14 whitespace-pre-wrap break-words">
<code className="font-mono text-sm leading-6 text-foreground whitespace-pre-wrap break-words">
{command}
</code>
</pre>
</div>
)
}

View file

@ -2,23 +2,51 @@ import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '@/shared/lib/utils'
interface MarkdownRendererProps {
content: string
className?: string
}
function stripFrontmatter(content: string) {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const markdown = stripFrontmatter(content).trim()
return (
<div className={className}>
<div
className={cn(
'max-w-none text-sm leading-7 text-foreground',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary/80',
'[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground',
'[&_code]:rounded-md [&_code]:bg-muted/70 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
'[&_h1]:mt-0 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:font-heading [&_h1]:leading-tight',
'[&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:border-border/60 [&_h2]:pb-2 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:font-heading',
'[&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:font-heading',
'[&_h4]:mt-6 [&_h4]:mb-2 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:font-heading',
'[&_hr]:my-8 [&_hr]:border-border/60',
'[&_img]:rounded-xl [&_img]:border [&_img]:border-border/60',
'[&_li]:my-1.5',
'[&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6',
'[&_p]:my-4',
'[&_pre]:my-5 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-slate-950 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-slate-100',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
'[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden',
'[&_tbody_tr]:border-t [&_tbody_tr]:border-border/60',
'[&_td]:border [&_td]:border-border/60 [&_td]:px-3 [&_td]:py-2 [&_td]:align-top',
'[&_th]:border [&_th]:border-border/60 [&_th]:bg-muted/50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold',
'[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6',
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
components={{
div: (props) => <div className="prose prose-sm dark:prose-invert max-w-none" {...props} />,
}}
>
{content}
{markdown}
</ReactMarkdown>
</div>
)

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Star } from 'lucide-react'
import { useUserRating, useRate } from './use-rating'
import { useAuth } from '@/features/auth/use-auth'
@ -9,6 +10,7 @@ interface RatingInputProps {
}
export function RatingInput({ skillId, onRequireLogin }: RatingInputProps) {
const { t } = useTranslation()
const { data: userRating, isLoading } = useUserRating(skillId)
const rateMutation = useRate(skillId)
const { isAuthenticated } = useAuth()
@ -56,7 +58,7 @@ export function RatingInput({ skillId, onRequireLogin }: RatingInputProps) {
</div>
{currentRating > 0 && (
<span className="text-sm text-muted-foreground">
: {currentRating}
{t('ratingInput.yourRating', { score: currentRating })}
</span>
)}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { useStar, useToggleStar } from './use-star'
import { Star } from 'lucide-react'
@ -10,6 +11,7 @@ interface StarButtonProps {
}
export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonProps) {
const { t } = useTranslation()
const { data: starStatus, isLoading } = useStar(skillId)
const toggleMutation = useToggleStar(skillId)
const { isAuthenticated } = useAuth()
@ -36,7 +38,7 @@ export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonPro
disabled={toggleMutation.isPending}
>
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
{starStatus.starred ? '已收藏' : '收藏'} ({starCount})
{starStatus.starred ? t('starButton.starred') : t('starButton.star')} ({starCount})
</Button>
)
}

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tokenApi } from '@/api/client'
import {
@ -20,6 +21,7 @@ interface CreateTokenDialogProps {
}
export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null)
@ -53,17 +55,17 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
{!createdToken ? (
<>
<DialogHeader>
<DialogTitle> API Token</DialogTitle>
<DialogTitle>{t('createToken.title')}</DialogTitle>
<DialogDescription>
API Token CLI API 访
{t('createToken.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="token-name">Token </Label>
<Label htmlFor="token-name">{t('createToken.nameLabel')}</Label>
<Input
id="token-name"
placeholder="例如: my-cli-token"
placeholder={t('createToken.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
@ -76,33 +78,33 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
{t('dialog.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending ? '创建中...' : '创建'}
{createMutation.isPending ? t('createToken.creating') : t('createToken.create')}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Token </DialogTitle>
<DialogTitle>{t('createToken.successTitle')}</DialogTitle>
<DialogDescription>
Token
{t('createToken.successDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Token</Label>
<Label>{t('createToken.tokenLabel')}</Label>
<div className="rounded-md bg-muted p-3 font-mono text-sm break-all">
{createdToken.token}
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Label>{t('createToken.nameDisplay')}</Label>
<div className="text-sm">{createdToken.name}</div>
</div>
</div>
@ -112,10 +114,10 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
navigator.clipboard.writeText(createdToken.token)
}}
>
Token
{t('createToken.copyToken')}
</Button>
<Button variant="outline" onClick={handleClose}>
{t('dialog.close')}
</Button>
</DialogFooter>
</>

View file

@ -25,19 +25,383 @@
"title": "Easy Integration",
"description": "Seamlessly integrate with your existing tools"
}
}
},
"badge": "Enterprise Skill Registry",
"tagline": "Publish, Discover, Manage",
"taglineHighlight": " Agent Skills",
"description": "Self-hosted private skill registry, providing secure and efficient skill sharing and collaboration for teams",
"publishSkill": "Publish Skill",
"statsSkills": "Skills",
"statsDownloads": "Downloads",
"statsTeams": "Teams",
"whyTitle": "Why Choose",
"whyDescription": "Enterprise-grade skill management platform with complete publishing, discovery, and governance capabilities",
"featuresList": {
"privateDeploy": {
"title": "Private Deployment",
"description": "Fully self-hosted with data sovereignty. One-click deploy to your infrastructure, running securely behind your firewall."
},
"versionControl": {
"title": "Version Control",
"description": "Semantic versioning, custom tags (beta, stable), automatic latest version tracking."
},
"smartSearch": {
"title": "Smart Search",
"description": "Full-text search with multi-dimensional filtering by namespace, downloads, ratings, and time."
},
"teamwork": {
"title": "Team Collaboration",
"description": "Namespace management, role-based access control (Owner/Admin/Member), publishing policy configuration."
},
"governance": {
"title": "Review & Governance",
"description": "Team-level review, platform-level approval, full audit trail, meeting compliance requirements."
},
"cliFirst": {
"title": "CLI First",
"description": "Native REST API, compatible with existing ClawHub CLI tools, no client-side changes needed."
}
},
"ctaTitle": "Ready to Get Started?",
"ctaDescription": "Start your local development environment with a single command and experience the complete skill management workflow",
"ctaButton": "Try Now",
"footerDocs": "Docs",
"footerGithub": "GitHub",
"footerCommunity": "Community"
},
"home": {
"subtitle": "Modern Skill Registry",
"description": "Efficient skill management, distribution, and collaboration platform for developers",
"browseSkills": "Browse Skills",
"publishSkill": "Publish Skill",
"popularTitle": "Popular Downloads",
"popularDescription": "Most popular skills in the community",
"latestTitle": "Latest Releases",
"latestDescription": "Freshly published skills",
"viewAll": "View All →"
},
"search": {
"title": "Search Skills",
"placeholder": "Search skills...",
"sort": {
"label": "Sort:",
"relevance": "Relevance",
"downloads": "Downloads",
"stars": "Stars",
"newest": "Newest"
},
"noResults": "No skills found",
"results": "{{count}} skills found"
"noResults": "No results found",
"noResultsFor": "No skills found matching \"{{q}}\"",
"enterKeyword": "Please enter a search keyword",
"results": "{{count}} skills found",
"resultCount": "Found <1>{{count}}</1> results"
},
"searchBar": {
"placeholder": "Search skills...",
"button": "Search"
},
"login": {
"title": "Login to SkillHub",
"subtitle": "Choose a method to continue",
"tabPassword": "Password",
"tabOAuth": "GitHub",
"username": "Username",
"password": "Password",
"usernamePlaceholder": "Enter username",
"passwordPlaceholder": "Enter password",
"submitting": "Logging in...",
"submit": "Login",
"noAccount": "Don't have an account?",
"register": "Sign up now",
"oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.",
"agreementPrefix": "By logging in, you agree to our",
"terms": "Terms of Service",
"and": "and",
"privacy": "Privacy Policy"
},
"register": {
"title": "Create Account",
"subtitle": "Register locally or sign in directly with OAuth.",
"tabLocal": "Local Account",
"tabOAuth": "OAuth",
"username": "Username",
"email": "Email",
"password": "Password",
"usernamePlaceholder": "3-64 characters: letters, numbers, or underscores",
"emailPlaceholder": "Optional, for account identification",
"passwordPlaceholder": "At least 8 characters with 3 character types",
"submitting": "Registering...",
"submit": "Register & Login",
"hasAccount": "Already have an account?",
"login": "Back to login",
"oauthHint": "Sign in directly with your existing OAuth account, no local password needed."
},
"device": {
"title": "Device Authorization",
"subtitle": "Enter the 8-digit user code shown on your device",
"codeLabel": "User Code",
"codeHint": "Format: XXXX-XXXX (paste supported)",
"incompleteCode": "Please enter the complete 8-digit user code",
"success": "Device authorized successfully!",
"defaultError": "Authorization failed, please check the user code",
"submitting": "Authorizing...",
"submit": "Authorize Device",
"notice": "After authorization, the device will have access to your account"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Manage your account and API Tokens",
"userInfo": "User Info",
"userInfoDesc": "Your account details",
"loginVia": "Logged in via {{provider}}",
"platformRoles": "Platform Roles",
"starsAndRatings": "Stars & Ratings",
"viewStars": "View My Stars",
"credentials": "Credentials",
"openTokens": "Open Token Page",
"governanceTitle": "Review & Governance",
"viewPromotions": "View Promotions"
},
"mySkills": {
"title": "My Skills",
"subtitle": "Manage your published skills",
"publishNew": "Publish New Skill",
"emptyTitle": "No skills yet",
"emptyDescription": "Start publishing your first skill",
"publishSkill": "Publish Skill"
},
"myNamespaces": {
"title": "My Namespaces",
"subtitle": "Manage your namespaces and teams",
"create": "Create Namespace",
"typeGlobal": "Global",
"typeTeam": "Team",
"manageMembers": "Manage Members",
"reviewTasks": "Review Tasks",
"emptyTitle": "No namespaces yet",
"emptyDescription": "Create a namespace to organize your skills"
},
"tokens": {
"pageTitle": "Token Management",
"pageSubtitle": "Manage access credentials for CLI and API"
},
"reviews": {
"title": "Review Center",
"subtitle": "Manage skill version reviews",
"tabPending": "Pending",
"tabApproved": "Approved",
"tabRejected": "Rejected",
"empty": "No review tasks",
"colSkill": "Skill",
"colVersion": "Version",
"colSubmitter": "Submitted By",
"colSubmitTime": "Submitted At",
"colReviewer": "Reviewed By",
"colReviewTime": "Reviewed At"
},
"stars": {
"title": "My Stars",
"subtitle": "View your starred skills",
"empty": "No starred skills yet"
},
"promotions": {
"title": "Promotion Review",
"subtitle": "Review team skill promotion requests to global namespace",
"tabPending": "Pending",
"tabApproved": "Approved",
"tabRejected": "Rejected",
"commentPlaceholder": "Review comment (optional)",
"approve": "Approve",
"reject": "Reject",
"empty": "No promotion requests"
},
"adminUsers": {
"title": "User Management",
"subtitle": "Manage platform users and permissions",
"searchPlaceholder": "Search username or email...",
"filterAll": "All",
"filterActive": "Active",
"filterPending": "Pending",
"filterDisabled": "Disabled",
"empty": "No user data",
"colUsername": "Username",
"colEmail": "Email",
"colStatus": "Status",
"colRole": "Role",
"colCreatedAt": "Created At",
"colActions": "Actions",
"statusActive": "Active",
"statusPending": "Pending",
"statusDisabled": "Disabled",
"changeRole": "Change Role",
"approveUser": "Approve",
"disable": "Disable",
"enable": "Enable",
"totalRecords": "Total {{total}} records, page {{page}}",
"prevPage": "Previous",
"nextPage": "Next",
"changeRoleTitle": "Change User Role",
"changeRoleDesc": "Assign a new role to user {{username}}",
"roleLabel": "Role",
"selectRole": "Select role",
"roleUser": "User",
"roleReviewer": "Reviewer",
"roleUserAdmin": "User Admin",
"roleAuditor": "Auditor",
"roleSuperAdmin": "Super Admin",
"confirmAction": "Confirm Action",
"confirmDisable": "Are you sure you want to disable user {{username}}?",
"confirmEnable": "Are you sure you want to enable user {{username}}?"
},
"auditLog": {
"title": "Audit Log",
"subtitle": "View system operation records",
"filterAll": "All",
"filterCliPublish": "CLI Publish",
"filterCompatPublish": "Compat Publish",
"filterReviewApprove": "Review Approved",
"filterReviewReject": "Review Rejected",
"filterPromotionApprove": "Promotion Approved",
"filterYankVersion": "Version Yanked",
"userIdPlaceholder": "User ID...",
"empty": "No audit logs",
"colTime": "Time",
"colAction": "Action",
"colUserId": "User ID",
"colUsername": "Username",
"colIp": "IP Address",
"colDetail": "Details",
"totalRecords": "Total {{total}} records, page {{page}}",
"prevPage": "Previous",
"nextPage": "Next"
},
"security": {
"title": "Security Settings",
"subtitle": "Update your password when local account login is enabled.",
"currentPassword": "Current Password",
"newPassword": "New Password",
"success": "Password changed successfully",
"defaultError": "Failed to change password",
"submitting": "Submitting...",
"submit": "Update Password"
},
"accounts": {
"initiateTitle": "Initiate Account Merge",
"initiateDesc": "Enter the secondary account identifier. Supports local username or `provider:subject` format for external identities.",
"secondaryLabel": "Secondary Identifier",
"secondaryPlaceholder": "e.g.: other_user or github:123456",
"initiating": "Initiating...",
"initiate": "Initiate Merge",
"initiateSuccess": "Merge request created, secondary={{secondaryUserId}}",
"initiateError": "Failed to initiate merge",
"verifyTitle": "Verify & Complete Merge",
"verifyDesc": "Complete token verification first, then confirm to execute data migration.",
"mergeRequestId": "Merge Request ID",
"verificationToken": "Verification Token",
"verifying": "Verifying...",
"verify": "Complete Merge",
"verifySuccess": "Verification successful, confirm to execute the merge",
"verifyError": "Merge verification failed",
"confirming": "Confirming...",
"confirm": "Confirm & Complete Merge",
"confirmSuccess": "Account merge completed",
"confirmError": "Merge confirmation failed"
},
"namespace": {
"notFound": "Namespace not found",
"skillList": "Skills",
"emptyTitle": "No skills",
"emptyDescription": "No skills have been published in this namespace yet"
},
"skillDetail": {
"notFound": "Skill not found",
"notFoundDesc": "This skill may have been deleted or never existed",
"tabReadme": "README",
"tabFiles": "Files",
"tabVersions": "Versions",
"noReadme": "No README",
"noFiles": "No files",
"noVersions": "No versions",
"fileCount": "{{count}} files",
"version": "Version",
"downloads": "Downloads",
"rating": "Rating",
"ratingNone": "None",
"namespaceLabel": "Namespace",
"loginToRate": "Login to star and rate",
"install": "Install",
"download": "Download",
"governance": "Governance",
"processing": "Processing...",
"hideSkill": "Hide Skill",
"unhideSkill": "Unhide Skill",
"yankVersion": "Yank Current Version"
},
"members": {
"title": "Member Management",
"addMember": "Add Member",
"colUserId": "User ID",
"colRole": "Role",
"colJoinedAt": "Joined At",
"colActions": "Actions",
"remove": "Remove",
"empty": "No members",
"namespaceNotFound": "Namespace not found"
},
"upload": {
"dropHint": "Drop to upload...",
"dragHint": "Drag a ZIP file here, or click to select",
"formatHint": "Only .zip format supported"
},
"layout": {
"footerDescription": "Modern skill registry, providing efficient skill management and distribution for developers."
},
"nsReviews": {
"title": "Namespace Reviews",
"loadingNamespace": "Loading namespace info",
"reviewsFor": "Review tasks for {{name}}",
"empty": "No review records",
"version": "Version {{version}}",
"tabPending": "Pending",
"tabApproved": "Approved",
"tabRejected": "Rejected"
},
"fileTree": {
"title": "Files ({{count}})"
},
"loginButton": {
"loading": "Loading...",
"loginWith": "Login with {{name}}"
},
"ratingInput": {
"yourRating": "Your rating: {{score}} stars"
},
"createToken": {
"title": "Create API Token",
"description": "Create a new API Token for CLI or API access",
"nameLabel": "Token Name",
"namePlaceholder": "e.g.: my-cli-token",
"creating": "Creating...",
"create": "Create",
"successTitle": "Token Created",
"successDescription": "Copy and save this token now. It will only be shown once.",
"tokenLabel": "Token",
"nameDisplay": "Name",
"copyToken": "Copy Token"
},
"starButton": {
"starred": "Starred",
"star": "Star"
},
"copyButton": {
"copied": "Copied",
"copy": "Copy"
},
"pagination": {
"prev": "Previous",
"next": "Next",
"pagePrefix": "Page",
"pageSuffix": ""
},
"user": {
"menu": {

View file

@ -25,19 +25,383 @@
"title": "轻松集成",
"description": "无缝集成到您现有的工具中"
}
}
},
"badge": "企业级技能注册中心",
"tagline": "发布、发现、管理",
"taglineHighlight": " Agent 技能包",
"description": "自托管的私有技能注册平台,为团队提供安全、高效的技能共享与协作空间",
"publishSkill": "发布技能",
"statsSkills": "技能包",
"statsDownloads": "下载量",
"statsTeams": "团队",
"whyTitle": "为什么选择",
"whyDescription": "专为企业打造的技能管理平台,提供完整的发布、发现、治理能力",
"featuresList": {
"privateDeploy": {
"title": "私有部署",
"description": "完全自托管,数据主权在您手中。一键部署到您的基础设施,防火墙后安全运行。"
},
"versionControl": {
"title": "版本管理",
"description": "语义化版本控制自定义标签beta、stable自动追踪 latest 版本。"
},
"smartSearch": {
"title": "智能搜索",
"description": "全文搜索,支持命名空间、下载量、评分、时间等多维度筛选。"
},
"teamwork": {
"title": "团队协作",
"description": "命名空间管理角色权限控制Owner/Admin/Member发布策略配置。"
},
"governance": {
"title": "审核治理",
"description": "团队内审核,平台级审批,全流程审计日志,满足合规要求。"
},
"cliFirst": {
"title": "CLI 优先",
"description": "原生 REST API兼容现有 ClawHub CLI 工具,无需客户端改动。"
}
},
"ctaTitle": "准备好开始了吗?",
"ctaDescription": "一条命令即可启动本地开发环境,体验完整的技能管理流程",
"ctaButton": "立即体验",
"footerDocs": "文档",
"footerGithub": "GitHub",
"footerCommunity": "社区"
},
"home": {
"subtitle": "现代化的技能注册中心",
"description": "为开发者提供高效的技能管理、分发和协作平台",
"browseSkills": "浏览技能",
"publishSkill": "发布技能",
"popularTitle": "热门下载",
"popularDescription": "社区最受欢迎的技能",
"latestTitle": "最新发布",
"latestDescription": "刚刚发布的新技能",
"viewAll": "查看全部 →"
},
"search": {
"title": "搜索技能",
"placeholder": "搜索技能...",
"sort": {
"label": "排序:",
"relevance": "相关性",
"downloads": "下载量",
"stars": "星标数",
"newest": "最新"
},
"noResults": "未找到技能",
"results": "找到 {{count}} 个技能"
"noResults": "未找到结果",
"noResultsFor": "没有找到与 \"{{q}}\" 相关的技能",
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果"
},
"searchBar": {
"placeholder": "搜索技能...",
"button": "搜索"
},
"login": {
"title": "登录 SkillHub",
"subtitle": "选择一个方式登录以继续",
"tabPassword": "账号密码",
"tabOAuth": "GitHub",
"username": "用户名",
"password": "密码",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"submitting": "登录中...",
"submit": "登录",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
"agreementPrefix": "登录即表示你同意我们的",
"terms": "服务条款",
"and": "和",
"privacy": "隐私政策"
},
"register": {
"title": "创建账号",
"subtitle": "支持本地注册,也可以直接使用 OAuth 登录进入平台。",
"tabLocal": "本地账号",
"tabOAuth": "OAuth",
"username": "用户名",
"email": "邮箱",
"password": "密码",
"usernamePlaceholder": "3-64 位字母、数字或下划线",
"emailPlaceholder": "可选,用于后续账号识别",
"passwordPlaceholder": "至少 8 位,包含 3 种字符类型",
"submitting": "注册中...",
"submit": "注册并登录",
"hasAccount": "已有账号?",
"login": "返回登录",
"oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。"
},
"device": {
"title": "设备授权",
"subtitle": "请输入设备上显示的 8 位用户码",
"codeLabel": "用户码",
"codeHint": "格式: XXXX-XXXX (支持粘贴)",
"incompleteCode": "请输入完整的 8 位用户码",
"success": "设备授权成功!",
"defaultError": "授权失败,请检查用户码是否正确",
"submitting": "授权中...",
"submit": "授权设备",
"notice": "授权后,设备将可以访问你的账户"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "管理你的账户和 API Tokens",
"userInfo": "用户信息",
"userInfoDesc": "你的账户详情",
"loginVia": "通过 {{provider}} 登录",
"platformRoles": "平台角色",
"starsAndRatings": "收藏与评分",
"viewStars": "查看我的收藏",
"credentials": "访问凭证",
"openTokens": "打开 Token 页面",
"governanceTitle": "审核与治理",
"viewPromotions": "查看提升审核"
},
"mySkills": {
"title": "我的技能",
"subtitle": "管理你发布的技能",
"publishNew": "发布新技能",
"emptyTitle": "还没有技能",
"emptyDescription": "开始发布你的第一个技能吧",
"publishSkill": "发布技能"
},
"myNamespaces": {
"title": "我的命名空间",
"subtitle": "管理你的命名空间和团队",
"create": "创建命名空间",
"typeGlobal": "全局",
"typeTeam": "团队",
"manageMembers": "管理成员",
"reviewTasks": "审核任务",
"emptyTitle": "还没有命名空间",
"emptyDescription": "创建一个命名空间来组织你的技能"
},
"tokens": {
"pageTitle": "Token 管理",
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
},
"reviews": {
"title": "审核中心",
"subtitle": "管理技能版本审核",
"tabPending": "待审核",
"tabApproved": "已通过",
"tabRejected": "已拒绝",
"empty": "暂无审核任务",
"colSkill": "技能",
"colVersion": "版本",
"colSubmitter": "提交者",
"colSubmitTime": "提交时间",
"colReviewer": "审核者",
"colReviewTime": "审核时间"
},
"stars": {
"title": "我的收藏",
"subtitle": "查看你标记过的技能",
"empty": "还没有收藏任何技能"
},
"promotions": {
"title": "提升审核",
"subtitle": "审核团队技能提升到全局空间的申请",
"tabPending": "待审核",
"tabApproved": "已通过",
"tabRejected": "已拒绝",
"commentPlaceholder": "审核意见(可选)",
"approve": "通过",
"reject": "拒绝",
"empty": "暂无提升申请"
},
"adminUsers": {
"title": "用户管理",
"subtitle": "管理平台用户和权限",
"searchPlaceholder": "搜索用户名或邮箱...",
"filterAll": "全部",
"filterActive": "活跃",
"filterPending": "待审批",
"filterDisabled": "已禁用",
"empty": "暂无用户数据",
"colUsername": "用户名",
"colEmail": "邮箱",
"colStatus": "状态",
"colRole": "角色",
"colCreatedAt": "创建时间",
"colActions": "操作",
"statusActive": "活跃",
"statusPending": "待审批",
"statusDisabled": "已禁用",
"changeRole": "修改角色",
"approveUser": "审批通过",
"disable": "禁用",
"enable": "启用",
"totalRecords": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页",
"changeRoleTitle": "修改用户角色",
"changeRoleDesc": "为用户 {{username}} 分配新角色",
"roleLabel": "角色",
"selectRole": "选择角色",
"roleUser": "普通用户",
"roleReviewer": "审核员",
"roleUserAdmin": "用户管理员",
"roleAuditor": "审计员",
"roleSuperAdmin": "超级管理员",
"confirmAction": "确认操作",
"confirmDisable": "确定要禁用用户 {{username}} 吗?",
"confirmEnable": "确定要启用用户 {{username}} 吗?"
},
"auditLog": {
"title": "审计日志",
"subtitle": "查看系统操作记录",
"filterAll": "全部",
"filterCliPublish": "CLI 发布",
"filterCompatPublish": "Compat 发布",
"filterReviewApprove": "审核通过",
"filterReviewReject": "审核拒绝",
"filterPromotionApprove": "提升通过",
"filterYankVersion": "版本撤回",
"userIdPlaceholder": "用户 ID...",
"empty": "暂无审计日志",
"colTime": "时间",
"colAction": "操作",
"colUserId": "用户 ID",
"colUsername": "用户名",
"colIp": "IP 地址",
"colDetail": "详情",
"totalRecords": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页"
},
"security": {
"title": "安全设置",
"subtitle": "已启用本地账号密码登录时,可以在这里更新密码。",
"currentPassword": "当前密码",
"newPassword": "新密码",
"success": "密码修改成功",
"defaultError": "修改密码失败",
"submitting": "提交中...",
"submit": "更新密码"
},
"accounts": {
"initiateTitle": "发起账号合并",
"initiateDesc": "输入 secondary 账号标识。支持本地用户名,或 `provider:subject` 格式的外部身份。",
"secondaryLabel": "Secondary 标识",
"secondaryPlaceholder": "例如other_user 或 github:123456",
"initiating": "发起中...",
"initiate": "发起合并",
"initiateSuccess": "已创建合并请求secondary={{secondaryUserId}}",
"initiateError": "发起合并失败",
"verifyTitle": "验证并完成合并",
"verifyDesc": "先完成 token 验证,再单独确认执行数据迁移。",
"mergeRequestId": "Merge Request ID",
"verificationToken": "Verification Token",
"verifying": "验证中...",
"verify": "完成合并",
"verifySuccess": "验证成功,确认后将执行正式合并",
"verifyError": "验证合并失败",
"confirming": "确认中...",
"confirm": "确认并完成合并",
"confirmSuccess": "账号合并已完成",
"confirmError": "确认合并失败"
},
"namespace": {
"notFound": "命名空间不存在",
"skillList": "技能列表",
"emptyTitle": "暂无技能",
"emptyDescription": "该命名空间下还没有发布任何技能"
},
"skillDetail": {
"notFound": "技能不存在",
"notFoundDesc": "该技能可能已被删除或从未存在",
"tabReadme": "README",
"tabFiles": "文件",
"tabVersions": "版本",
"noReadme": "暂无 README",
"noFiles": "暂无文件",
"noVersions": "暂无版本",
"fileCount": "{{count}} 个文件",
"version": "版本",
"downloads": "下载量",
"rating": "评分",
"ratingNone": "暂无",
"namespaceLabel": "命名空间",
"loginToRate": "登录后可以收藏和评分",
"install": "安装",
"download": "下载",
"governance": "治理操作",
"processing": "处理中...",
"hideSkill": "隐藏技能",
"unhideSkill": "恢复技能",
"yankVersion": "撤回当前版本"
},
"members": {
"title": "成员管理",
"addMember": "添加成员",
"colUserId": "用户 ID",
"colRole": "角色",
"colJoinedAt": "加入时间",
"colActions": "操作",
"remove": "移除",
"empty": "暂无成员",
"namespaceNotFound": "命名空间不存在"
},
"upload": {
"dropHint": "放开以上传文件...",
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",
"formatHint": "仅支持 .zip 格式"
},
"layout": {
"footerDescription": "现代化的技能注册中心,为开发者提供高效的技能管理和分发平台。"
},
"nsReviews": {
"title": "命名空间审核",
"loadingNamespace": "加载命名空间信息中",
"reviewsFor": "{{name}} 的审核任务",
"empty": "暂无审核记录",
"version": "版本 {{version}}",
"tabPending": "待审核",
"tabApproved": "已通过",
"tabRejected": "已拒绝"
},
"fileTree": {
"title": "文件列表 ({{count}})"
},
"loginButton": {
"loading": "加载中...",
"loginWith": "使用 {{name}} 登录"
},
"ratingInput": {
"yourRating": "你的评分: {{score}} 星"
},
"createToken": {
"title": "创建 API Token",
"description": "创建一个新的 API Token 用于 CLI 或 API 访问",
"nameLabel": "Token 名称",
"namePlaceholder": "例如: my-cli-token",
"creating": "创建中...",
"create": "创建",
"successTitle": "Token 创建成功",
"successDescription": "请立即复制并保存此 Token它只会显示一次",
"tokenLabel": "Token",
"nameDisplay": "名称",
"copyToken": "复制 Token"
},
"starButton": {
"starred": "已收藏",
"star": "收藏"
},
"copyButton": {
"copied": "已复制",
"copy": "复制"
},
"pagination": {
"prev": "上一页",
"next": "下一页",
"pagePrefix": "第",
"pageSuffix": "页"
},
"user": {
"menu": {

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
@ -14,6 +15,7 @@ import {
import { useAuditLog } from '@/features/admin/use-audit-log'
export function AuditLogPage() {
const { t, i18n } = useTranslation()
const [actionFilter, setActionFilter] = useState<string>('')
const [userIdFilter, setUserIdFilter] = useState('')
const [page, setPage] = useState(0)
@ -26,29 +28,29 @@ export function AuditLogPage() {
})
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
return new Date(dateString).toLocaleString(i18n.language)
}
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('auditLog.title')}</h1>
<p className="text-muted-foreground text-lg">{t('auditLog.subtitle')}</p>
</div>
<Card className="p-5">
<div className="flex gap-4">
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
<option value=""></option>
<option value="CLI_PUBLISH">CLI </option>
<option value="COMPAT_PUBLISH">Compat </option>
<option value="REVIEW_APPROVE"></option>
<option value="REVIEW_REJECT"></option>
<option value="PROMOTION_APPROVE"></option>
<option value="YANK_SKILL_VERSION"></option>
<option value="">{t('auditLog.filterAll')}</option>
<option value="CLI_PUBLISH">{t('auditLog.filterCliPublish')}</option>
<option value="COMPAT_PUBLISH">{t('auditLog.filterCompatPublish')}</option>
<option value="REVIEW_APPROVE">{t('auditLog.filterReviewApprove')}</option>
<option value="REVIEW_REJECT">{t('auditLog.filterReviewReject')}</option>
<option value="PROMOTION_APPROVE">{t('auditLog.filterPromotionApprove')}</option>
<option value="YANK_SKILL_VERSION">{t('auditLog.filterYankVersion')}</option>
</Select>
<Input
placeholder="用户 ID..."
placeholder={t('auditLog.userIdPlaceholder')}
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="w-[200px]"
@ -64,7 +66,7 @@ export function AuditLogPage() {
</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground"></p>
<p className="text-muted-foreground">{t('auditLog.empty')}</p>
</Card>
) : (
<>
@ -72,12 +74,12 @@ export function AuditLogPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead>IP </TableHead>
<TableHead></TableHead>
<TableHead>{t('auditLog.colTime')}</TableHead>
<TableHead>{t('auditLog.colAction')}</TableHead>
<TableHead>{t('auditLog.colUserId')}</TableHead>
<TableHead>{t('auditLog.colUsername')}</TableHead>
<TableHead>{t('auditLog.colIp')}</TableHead>
<TableHead>{t('auditLog.colDetail')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -99,7 +101,7 @@ export function AuditLogPage() {
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{data.total} {page + 1}
{t('auditLog.totalRecords', { total: data.total, page: page + 1 })}
</p>
<div className="flex gap-2">
<Button
@ -108,7 +110,7 @@ export function AuditLogPage() {
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
{t('auditLog.prevPage')}
</Button>
<Button
variant="outline"
@ -116,7 +118,7 @@ export function AuditLogPage() {
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
{t('auditLog.nextPage')}
</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
@ -24,6 +25,7 @@ import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdate
import type { AdminUser } from '@/features/admin/use-admin-users'
export function AdminUsersPage() {
const { t, i18n } = useTranslation()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [page, setPage] = useState(0)
@ -46,7 +48,7 @@ export function AdminUsersPage() {
const enableUserMutation = useEnableUser()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
return new Date(dateString).toLocaleString(i18n.language)
}
const handleChangeRole = (user: AdminUser) => {
@ -90,23 +92,23 @@ export function AdminUsersPage() {
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('adminUsers.title')}</h1>
<p className="text-muted-foreground text-lg">{t('adminUsers.subtitle')}</p>
</div>
<Card className="p-5">
<div className="flex gap-4">
<Input
placeholder="搜索用户名或邮箱..."
placeholder={t('adminUsers.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="PENDING"></option>
<option value="DISABLED"></option>
<option value="">{t('adminUsers.filterAll')}</option>
<option value="ACTIVE">{t('adminUsers.filterActive')}</option>
<option value="PENDING">{t('adminUsers.filterPending')}</option>
<option value="DISABLED">{t('adminUsers.filterDisabled')}</option>
</Select>
</div>
</Card>
@ -119,7 +121,7 @@ export function AdminUsersPage() {
</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground"></p>
<p className="text-muted-foreground">{t('adminUsers.empty')}</p>
</Card>
) : (
<>
@ -127,12 +129,12 @@ export function AdminUsersPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t('adminUsers.colUsername')}</TableHead>
<TableHead>{t('adminUsers.colEmail')}</TableHead>
<TableHead>{t('adminUsers.colStatus')}</TableHead>
<TableHead>{t('adminUsers.colRole')}</TableHead>
<TableHead>{t('adminUsers.colCreatedAt')}</TableHead>
<TableHead>{t('adminUsers.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -150,7 +152,7 @@ export function AdminUsersPage() {
: 'bg-red-500/10 text-red-400 border-red-500/20'
}`}
>
{user.status === 'ACTIVE' ? '活跃' : user.status === 'PENDING' ? '待审批' : '已禁用'}
{user.status === 'ACTIVE' ? t('adminUsers.statusActive') : user.status === 'PENDING' ? t('adminUsers.statusPending') : t('adminUsers.statusDisabled')}
</span>
</TableCell>
<TableCell>{user.platformRoles.join(', ')}</TableCell>
@ -162,7 +164,7 @@ export function AdminUsersPage() {
size="sm"
onClick={() => handleChangeRole(user)}
>
{t('adminUsers.changeRole')}
</Button>
{user.status === 'PENDING' && (
<Button
@ -170,7 +172,7 @@ export function AdminUsersPage() {
size="sm"
onClick={() => approveUserMutation.mutate(user.userId)}
>
{t('adminUsers.approveUser')}
</Button>
)}
{user.status === 'ACTIVE' ? (
@ -179,7 +181,7 @@ export function AdminUsersPage() {
size="sm"
onClick={() => handleToggleStatus(user, 'ban')}
>
{t('adminUsers.disable')}
</Button>
) : (
<Button
@ -187,7 +189,7 @@ export function AdminUsersPage() {
size="sm"
onClick={() => handleToggleStatus(user, 'unban')}
>
{t('adminUsers.enable')}
</Button>
)}
</div>
@ -200,7 +202,7 @@ export function AdminUsersPage() {
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{data.total} {page + 1}
{t('adminUsers.totalRecords', { total: data.total, page: page + 1 })}
</p>
<div className="flex gap-2">
<Button
@ -209,7 +211,7 @@ export function AdminUsersPage() {
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
{t('adminUsers.prevPage')}
</Button>
<Button
variant="outline"
@ -217,7 +219,7 @@ export function AdminUsersPage() {
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
{t('adminUsers.nextPage')}
</Button>
</div>
</div>
@ -227,30 +229,30 @@ export function AdminUsersPage() {
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('adminUsers.changeRoleTitle')}</DialogTitle>
<DialogDescription>
{selectedUser?.username}
{t('adminUsers.changeRoleDesc', { username: selectedUser?.username })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="role"></Label>
<Label htmlFor="role">{t('adminUsers.roleLabel')}</Label>
<Select id="role" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
<option value=""></option>
<option value="USER"></option>
<option value="REVIEWER"></option>
<option value="USER_ADMIN"></option>
<option value="AUDITOR"></option>
<option value="SUPER_ADMIN"></option>
<option value="">{t('adminUsers.selectRole')}</option>
<option value="USER">{t('adminUsers.roleUser')}</option>
<option value="REVIEWER">{t('adminUsers.roleReviewer')}</option>
<option value="USER_ADMIN">{t('adminUsers.roleUserAdmin')}</option>
<option value="AUDITOR">{t('adminUsers.roleAuditor')}</option>
<option value="SUPER_ADMIN">{t('adminUsers.roleSuperAdmin')}</option>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
{t('dialog.cancel')}
</Button>
<Button onClick={confirmRoleChange} disabled={updateRoleMutation.isPending}>
{t('dialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
@ -259,17 +261,17 @@ export function AdminUsersPage() {
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('adminUsers.confirmAction')}</DialogTitle>
<DialogDescription>
{actionType === 'ban' ? '禁用' : '启用'} {selectedUser?.username}
{actionType === 'ban' ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) : t('adminUsers.confirmEnable', { username: selectedUser?.username })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>
{t('dialog.cancel')}
</Button>
<Button onClick={confirmStatusChange} disabled={disableUserMutation.isPending || enableUserMutation.isPending}>
{t('dialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,24 +1,26 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
export function DashboardPage() {
const { t } = useTranslation()
const { user } = useAuth()
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading text-foreground">Dashboard</h1>
<h1 className="text-4xl font-bold font-heading text-foreground">{t('dashboard.title')}</h1>
<p className="text-muted-foreground mt-2 text-lg">
API Tokens
{t('dashboard.subtitle')}
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle>{t('dashboard.userInfo')}</CardTitle>
<CardDescription>{t('dashboard.userInfoDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-5">
@ -34,13 +36,13 @@ export function DashboardPage() {
<div className="text-sm text-muted-foreground">{user?.email}</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
{user?.oauthProvider}
{t('dashboard.loginVia', { provider: user?.oauthProvider })}
</div>
</div>
</div>
{user?.platformRoles && user.platformRoles.length > 0 && (
<div className="space-y-3">
<div className="text-sm font-medium font-heading"></div>
<div className="text-sm font-medium font-heading">{t('dashboard.platformRoles')}</div>
<div className="flex flex-wrap gap-2">
{user.platformRoles.map((role: string) => (
<span
@ -58,21 +60,21 @@ export function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-5">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('dashboard.starsAndRatings')}</div>
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewStars')}
</Link>
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground">访</div>
<div className="text-sm text-muted-foreground">{t('dashboard.credentials')}</div>
<Link to="/dashboard/tokens" className="mt-2 inline-block font-semibold text-primary hover:underline">
Token
{t('dashboard.openTokens')}
</Link>
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('dashboard.governanceTitle')}</div>
<Link to="/dashboard/promotions" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewPromotions')}
</Link>
</Card>
</div>

View file

@ -1,4 +1,5 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
@ -7,6 +8,7 @@ import { useMyNamespaces } from '@/shared/hooks/use-skill-queries'
export function MyNamespacesPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { data: namespaces, isLoading } = useMyNamespaces()
const handleNamespaceClick = (slug: string) => {
@ -37,10 +39,10 @@ export function MyNamespacesPage() {
<div className="space-y-8 animate-fade-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('myNamespaces.title')}</h1>
<p className="text-muted-foreground text-lg">{t('myNamespaces.subtitle')}</p>
</div>
<Button disabled></Button>
<Button disabled>{t('myNamespaces.create')}</Button>
</div>
{namespaces && namespaces.length > 0 ? (
@ -60,7 +62,7 @@ export function MyNamespacesPage() {
</h3>
<NamespaceBadge
type={namespace.type}
name={namespace.type === 'GLOBAL' ? '全局' : '团队'}
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
/>
</div>
{namespace.description && (
@ -78,7 +80,7 @@ export function MyNamespacesPage() {
size="sm"
onClick={(e) => handleMembersClick(namespace.slug, e)}
>
{t('myNamespaces.manageMembers')}
</Button>
)}
<Button
@ -86,7 +88,7 @@ export function MyNamespacesPage() {
size="sm"
onClick={(e) => handleReviewsClick(namespace.slug, e)}
>
{t('myNamespaces.reviewTasks')}
</Button>
</div>
</div>
@ -95,9 +97,9 @@ export function MyNamespacesPage() {
</div>
) : (
<EmptyState
title="还没有命名空间"
description="创建一个命名空间来组织你的技能"
action={<Button disabled></Button>}
title={t('myNamespaces.emptyTitle')}
description={t('myNamespaces.emptyDescription')}
action={<Button disabled>{t('myNamespaces.create')}</Button>}
/>
)}
</div>

View file

@ -1,4 +1,5 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { EmptyState } from '@/shared/components/empty-state'
@ -6,6 +7,7 @@ import { useMySkills } from '@/shared/hooks/use-skill-queries'
export function MySkillsPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { data: skills, isLoading } = useMySkills()
const handleSkillClick = (namespace: string, slug: string) => {
@ -26,11 +28,11 @@ export function MySkillsPage() {
<div className="space-y-8 animate-fade-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('mySkills.title')}</h1>
<p className="text-muted-foreground text-lg">{t('mySkills.subtitle')}</p>
</div>
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishNew')}
</Button>
</div>
@ -72,11 +74,11 @@ export function MySkillsPage() {
</div>
) : (
<EmptyState
title="还没有技能"
description="开始发布你的第一个技能吧"
title={t('mySkills.emptyTitle')}
description={t('mySkills.emptyDescription')}
action={
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishSkill')}
</Button>
}
/>

View file

@ -1,11 +1,16 @@
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { useNamespaceDetail, useNamespaceMembers } from '@/shared/hooks/use-skill-queries'
export function NamespaceMembersPage() {
const { slug } = useParams({ from: '/dashboard/namespaces/$slug/members' })
const translation = useTranslation()
const t = translation.t
const language = translation.i18n.language
const params = useParams({ from: '/dashboard/namespaces/$slug/members' })
const slug = params.slug
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug)
@ -22,7 +27,7 @@ export function NamespaceMembersPage() {
if (!namespace) {
return (
<div className="text-center py-20 animate-fade-up">
<h2 className="text-2xl font-bold font-heading mb-2"></h2>
<h2 className="text-2xl font-bold font-heading mb-2">{t('members.namespaceNotFound')}</h2>
</div>
)
}
@ -33,8 +38,8 @@ export function NamespaceMembersPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold font-heading"></h2>
<Button disabled></Button>
<h2 className="text-2xl font-bold font-heading">{t('members.title')}</h2>
<Button disabled>{t('members.addMember')}</Button>
</div>
{isLoadingMembers ? (
@ -49,10 +54,10 @@ export function NamespaceMembersPage() {
<table className="w-full">
<thead>
<tr className="border-b border-border/40">
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground"> ID</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground"></th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground"></th>
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground"></th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colUserId')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colRole')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colJoinedAt')}</th>
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th>
</tr>
</thead>
<tbody>
@ -65,11 +70,11 @@ export function NamespaceMembersPage() {
</span>
</td>
<td className="p-4 text-sm text-muted-foreground">
{new Date(member.createdAt).toLocaleDateString('zh-CN')}
{new Date(member.createdAt).toLocaleDateString(language)}
</td>
<td className="p-4 text-right">
<Button variant="destructive" size="sm" disabled>
{t('members.remove')}
</Button>
</td>
</tr>
@ -80,7 +85,7 @@ export function NamespaceMembersPage() {
</Card>
) : (
<Card className="p-6 text-center text-muted-foreground">
{t('members.empty')}
</Card>
)}
</div>

View file

@ -1,17 +1,19 @@
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries'
import { useReviewList } from '@/features/review/use-review-list'
function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
const { t } = useTranslation()
const { data: pending } = useReviewList('PENDING', namespaceId)
const { data: approved } = useReviewList('APPROVED', namespaceId)
const { data: rejected } = useReviewList('REJECTED', namespaceId)
const renderItems = (items?: typeof pending) => {
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground"></Card>
return <Card className="p-10 text-center text-muted-foreground">{t('nsReviews.empty')}</Card>
}
return (
<Card className="divide-y divide-border/40">
@ -20,7 +22,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
<div className="flex items-center justify-between gap-4">
<div>
<div className="font-semibold font-heading">{review.namespace}/{review.skillSlug}</div>
<div className="text-sm text-muted-foreground"> {review.version}</div>
<div className="text-sm text-muted-foreground">{t('nsReviews.version', { version: review.version })}</div>
</div>
<div className="text-sm text-muted-foreground">{new Date(review.submittedAt).toLocaleString('zh-CN')}</div>
</div>
@ -36,9 +38,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
return (
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING"></TabsTrigger>
<TabsTrigger value="APPROVED"></TabsTrigger>
<TabsTrigger value="REJECTED"></TabsTrigger>
<TabsTrigger value="PENDING">{t('nsReviews.tabPending')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('nsReviews.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('nsReviews.tabRejected')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6">{renderItems(pending)}</TabsContent>
<TabsContent value="APPROVED" className="mt-6">{renderItems(approved)}</TabsContent>
@ -48,15 +50,16 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
}
export function NamespaceReviewsPage() {
const { t } = useTranslation()
const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' })
const { data: namespace } = useNamespaceDetail(slug)
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<h1 className="text-4xl font-bold font-heading mb-2">{t('nsReviews.title')}</h1>
<p className="text-muted-foreground text-lg">
{namespace ? `${namespace.displayName} 的审核任务` : '加载命名空间信息中'}
{namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
</p>
</div>
<ReviewListSection namespaceId={namespace?.id} />

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
@ -6,6 +7,7 @@ import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) {
const { t, i18n } = useTranslation()
const { data: items, isLoading } = usePromotionList(status)
const approveMutation = useApprovePromotion()
const rejectMutation = useRejectPromotion()
@ -16,7 +18,7 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
}
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground"></Card>
return <Card className="p-10 text-center text-muted-foreground">{t('promotions.empty')}</Card>
}
return (
@ -30,12 +32,12 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
{item.sourceVersion} {'->'} @{item.targetNamespace}
</div>
</div>
<div className="text-sm text-muted-foreground">{new Date(item.submittedAt).toLocaleString('zh-CN')}</div>
<div className="text-sm text-muted-foreground">{new Date(item.submittedAt).toLocaleString(i18n.language)}</div>
</div>
{status === 'PENDING' ? (
<>
<Input
placeholder="审核意见(可选)"
placeholder={t('promotions.commentPlaceholder')}
value={commentById[item.id] ?? ''}
onChange={(event) => setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))}
/>
@ -44,14 +46,14 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
onClick={() => approveMutation.mutate({ id: item.id, comment: commentById[item.id] })}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
{t('promotions.approve')}
</Button>
<Button
variant="destructive"
onClick={() => rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
{t('promotions.reject')}
</Button>
</div>
</>
@ -65,17 +67,18 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
}
export function PromotionsPage() {
const { t } = useTranslation()
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('promotions.title')}</h1>
<p className="text-muted-foreground text-lg">{t('promotions.subtitle')}</p>
</div>
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING"></TabsTrigger>
<TabsTrigger value="APPROVED"></TabsTrigger>
<TabsTrigger value="REJECTED"></TabsTrigger>
<TabsTrigger value="PENDING">{t('promotions.tabPending')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('promotions.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('promotions.tabRejected')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6"><PromotionSection status="PENDING" /></TabsContent>
<TabsContent value="APPROVED" className="mt-6"><PromotionSection status="APPROVED" /></TabsContent>

View file

@ -12,7 +12,7 @@ import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/r
export function ReviewDetailPage() {
const { id } = useParams({ from: '/dashboard/reviews/$id' })
const navigate = useNavigate()
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const taskId = Number(id)
const { data: review, isLoading } = useReviewDetail(taskId)
@ -25,7 +25,7 @@ export function ReviewDetailPage() {
const [rejectDialog, setRejectDialog] = useState(false)
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
return new Date(dateString).toLocaleString(i18n.language)
}
const handleApprove = async () => {

View file

@ -1,4 +1,5 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import {
@ -12,13 +13,14 @@ import {
import { useReviewList } from '@/features/review/use-review-list'
export function ReviewsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const { data: pendingReviews, isLoading: isPendingLoading } = useReviewList('PENDING')
const { data: approvedReviews, isLoading: isApprovedLoading } = useReviewList('APPROVED')
const { data: rejectedReviews, isLoading: isRejectedLoading } = useReviewList('REJECTED')
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
return new Date(dateString).toLocaleString(i18n.language)
}
const handleRowClick = (reviewId: number) => {
@ -39,7 +41,7 @@ export function ReviewsPage() {
if (!reviews || reviews.length === 0) {
return (
<Card className="p-12 text-center">
<p className="text-muted-foreground"></p>
<p className="text-muted-foreground">{t('reviews.empty')}</p>
</Card>
)
}
@ -49,12 +51,12 @@ export function ReviewsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{status !== 'PENDING' && <TableHead></TableHead>}
{status !== 'PENDING' && <TableHead></TableHead>}
<TableHead>{t('reviews.colSkill')}</TableHead>
<TableHead>{t('reviews.colVersion')}</TableHead>
<TableHead>{t('reviews.colSubmitter')}</TableHead>
<TableHead>{t('reviews.colSubmitTime')}</TableHead>
{status !== 'PENDING' && <TableHead>{t('reviews.colReviewer')}</TableHead>}
{status !== 'PENDING' && <TableHead>{t('reviews.colReviewTime')}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@ -93,15 +95,15 @@ export function ReviewsPage() {
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('reviews.title')}</h1>
<p className="text-muted-foreground text-lg">{t('reviews.subtitle')}</p>
</div>
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING"></TabsTrigger>
<TabsTrigger value="APPROVED"></TabsTrigger>
<TabsTrigger value="REJECTED"></TabsTrigger>
<TabsTrigger value="PENDING">{t('reviews.tabPending')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('reviews.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('reviews.tabRejected')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6">

View file

@ -1,9 +1,11 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SkillCard } from '@/features/skill/skill-card'
import { useMyStars } from '@/shared/hooks/use-skill-queries'
import { Card } from '@/shared/ui/card'
export function MyStarsPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: skills, isLoading } = useMyStars()
@ -20,12 +22,12 @@ export function MyStarsPage() {
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2"></h1>
<p className="text-muted-foreground text-lg"></p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('stars.title')}</h1>
<p className="text-muted-foreground text-lg">{t('stars.subtitle')}</p>
</div>
{!skills || skills.length === 0 ? (
<Card className="p-12 text-center text-muted-foreground"></Card>
<Card className="p-12 text-center text-muted-foreground">{t('stars.empty')}</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{skills.map((skill) => (

View file

@ -1,11 +1,13 @@
import { useTranslation } from 'react-i18next'
import { TokenList } from '@/features/token/token-list'
export function TokensPage() {
const { t } = useTranslation()
return (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">Token </h1>
<p className="text-muted-foreground text-lg"> CLI API 使访</p>
<h1 className="text-4xl font-bold font-heading mb-2">{t('tokens.pageTitle')}</h1>
<p className="text-muted-foreground text-lg">{t('tokens.pageSubtitle')}</p>
</div>
<TokenList />
</div>

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@ -16,6 +17,7 @@ async function authorizeDevice(userCode: string): Promise<void> {
}
export function DeviceAuthPage() {
const { t } = useTranslation()
const [part1, setPart1] = useState('')
const [part2, setPart2] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@ -61,7 +63,7 @@ export function DeviceAuthPage() {
e.preventDefault()
if (part1.length !== 4 || part2.length !== 4) {
setMessage({ type: 'error', text: '请输入完整的 8 位用户码' })
setMessage({ type: 'error', text: t('device.incompleteCode') })
return
}
@ -71,14 +73,14 @@ export function DeviceAuthPage() {
try {
await authorizeDevice(userCode)
setMessage({ type: 'success', text: '设备授权成功!' })
setMessage({ type: 'success', text: t('device.success') })
setPart1('')
setPart2('')
input1Ref.current?.focus()
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : '授权失败,请检查用户码是否正确'
text: error instanceof Error ? error.message : t('device.defaultError')
})
} finally {
setIsSubmitting(false)
@ -94,15 +96,15 @@ export function DeviceAuthPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h1 className="text-3xl font-bold font-heading"></h1>
<h1 className="text-3xl font-bold font-heading">{t('device.title')}</h1>
<p className="text-muted-foreground">
8
{t('device.subtitle')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label></Label>
<Label>{t('device.codeLabel')}</Label>
<div className="flex items-center gap-3">
<Input
ref={input1Ref}
@ -127,7 +129,7 @@ export function DeviceAuthPage() {
/>
</div>
<p className="text-sm text-muted-foreground">
格式: XXXX-XXXX ()
{t('device.codeHint')}
</p>
</div>
@ -148,12 +150,12 @@ export function DeviceAuthPage() {
className="w-full"
disabled={isSubmitting || part1.length !== 4 || part2.length !== 4}
>
{isSubmitting ? '授权中...' : '授权设备'}
{isSubmitting ? t('device.submitting') : t('device.submit')}
</Button>
</form>
<div className="text-center text-sm text-muted-foreground">
<p>访</p>
<p>{t('device.notice')}</p>
</div>
</Card>
</div>

View file

@ -1,4 +1,5 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SearchBar } from '@/features/search/search-bar'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
@ -6,6 +7,7 @@ import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { Button } from '@/shared/ui/button'
export function HomePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: popularSkills, isLoading: isLoadingPopular } = useSearchSkills({
@ -35,10 +37,10 @@ export function HomePage() {
SkillHub
</h1>
<p className="text-xl md:text-2xl text-muted-foreground font-light max-w-2xl mx-auto">
{t('home.subtitle')}
</p>
<p className="text-base text-muted-foreground/80 max-w-xl mx-auto">
{t('home.description')}
</p>
</div>
@ -48,10 +50,10 @@ export function HomePage() {
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}>
{t('home.browseSkills')}
</Button>
<Button size="lg" variant="outline" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('home.publishSkill')}
</Button>
</div>
</div>
@ -60,14 +62,14 @@ export function HomePage() {
<section className="space-y-6 animate-fade-up delay-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold font-heading text-foreground mb-2"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-3xl font-bold font-heading text-foreground mb-2">{t('home.popularTitle')}</h2>
<p className="text-muted-foreground">{t('home.popularDescription')}</p>
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0 } })}
>
{t('home.viewAll')}
</Button>
</div>
{isLoadingPopular ? (
@ -90,14 +92,14 @@ export function HomePage() {
<section className="space-y-6 animate-fade-up delay-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold font-heading text-foreground mb-2"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-3xl font-bold font-heading text-foreground mb-2">{t('home.latestTitle')}</h2>
<p className="text-muted-foreground">{t('home.latestDescription')}</p>
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0 } })}
>
{t('home.viewAll')}
</Button>
</div>
{isLoadingLatest ? (

View file

@ -1,9 +1,11 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SearchBar } from '@/features/search/search-bar'
import { Button } from '@/shared/ui/button'
import { useEffect, useRef, useState } from 'react'
export function LandingPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [stats] = useState({
@ -112,33 +114,33 @@ export function LandingPage() {
const features = [
{
icon: '🔒',
title: '私有部署',
description: '完全自托管,数据主权在您手中。一键部署到您的基础设施,防火墙后安全运行。',
title: t('landing.featuresList.privateDeploy.title'),
description: t('landing.featuresList.privateDeploy.description'),
},
{
icon: '📦',
title: '版本管理',
description: '语义化版本控制自定义标签beta、stable自动追踪 latest 版本。',
title: t('landing.featuresList.versionControl.title'),
description: t('landing.featuresList.versionControl.description'),
},
{
icon: '🔍',
title: '智能搜索',
description: '全文搜索,支持命名空间、下载量、评分、时间等多维度筛选。',
title: t('landing.featuresList.smartSearch.title'),
description: t('landing.featuresList.smartSearch.description'),
},
{
icon: '👥',
title: '团队协作',
description: '命名空间管理角色权限控制Owner/Admin/Member发布策略配置。',
title: t('landing.featuresList.teamwork.title'),
description: t('landing.featuresList.teamwork.description'),
},
{
icon: '✅',
title: '审核治理',
description: '团队内审核,平台级审批,全流程审计日志,满足合规要求。',
title: t('landing.featuresList.governance.title'),
description: t('landing.featuresList.governance.description'),
},
{
icon: '⚡',
title: 'CLI 优先',
description: '原生 REST API兼容现有 ClawHub CLI 工具,无需客户端改动。',
title: t('landing.featuresList.cliFirst.title'),
description: t('landing.featuresList.cliFirst.description'),
},
]
@ -157,7 +159,7 @@ export function LandingPage() {
<div className="flex flex-col items-center text-center space-y-12 pt-20 pb-32">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-cyan-500/10 border border-cyan-500/30 backdrop-blur-sm animate-fade-in">
<div className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse" />
<span className="text-sm text-cyan-300 font-medium"></span>
<span className="text-sm text-cyan-300 font-medium">{t('landing.badge')}</span>
</div>
<div className="space-y-6 max-w-5xl">
@ -167,11 +169,11 @@ export function LandingPage() {
</span>
</h1>
<p className="text-2xl md:text-3xl text-slate-300 font-light leading-relaxed animate-fade-up delay-1">
<span className="text-cyan-400 font-medium"> Agent </span>
{t('landing.tagline')}
<span className="text-cyan-400 font-medium">{t('landing.taglineHighlight')}</span>
</p>
<p className="text-lg text-slate-400 max-w-2xl mx-auto animate-fade-up delay-2">
{t('landing.description')}
</p>
</div>
@ -190,7 +192,7 @@ export function LandingPage() {
className="bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-white font-semibold px-8 py-6 text-lg rounded-xl shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 transition-all duration-300 hover:scale-105"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}
>
{t('landing.hero.exploreSkills')}
</Button>
<Button
size="lg"
@ -198,7 +200,7 @@ export function LandingPage() {
className="border-2 border-slate-600 hover:border-cyan-500 text-slate-200 hover:text-cyan-300 font-semibold px-8 py-6 text-lg rounded-xl backdrop-blur-sm bg-slate-800/30 hover:bg-slate-800/50 transition-all duration-300"
onClick={() => navigate({ to: '/dashboard/publish' })}
>
{t('landing.publishSkill')}
</Button>
</div>
@ -209,9 +211,9 @@ export function LandingPage() {
{value}
</div>
<div className="text-sm md:text-base text-slate-400 mt-2 capitalize">
{key === 'skills' && '技能包'}
{key === 'downloads' && '下载量'}
{key === 'teams' && '团队'}
{key === 'skills' && t('landing.statsSkills')}
{key === 'downloads' && t('landing.statsDownloads')}
{key === 'teams' && t('landing.statsTeams')}
</div>
</div>
))}
@ -221,10 +223,10 @@ export function LandingPage() {
<div className="py-20 space-y-16">
<div className="text-center space-y-4 animate-fade-up">
<h2 className="text-4xl md:text-5xl font-bold text-slate-100">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-violet-400">SkillHub</span>
{t('landing.whyTitle')} <span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-violet-400">SkillHub</span>
</h2>
<p className="text-lg text-slate-400 max-w-2xl mx-auto">
{t('landing.whyDescription')}
</p>
</div>
@ -255,10 +257,10 @@ export function LandingPage() {
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/5 to-violet-500/5 rounded-3xl blur-xl" />
<div className="relative space-y-6">
<h2 className="text-4xl md:text-5xl font-bold text-slate-100">
{t('landing.ctaTitle')}
</h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
{t('landing.ctaDescription')}
</p>
<div className="inline-block p-4 rounded-xl bg-slate-900/80 backdrop-blur-sm border border-slate-700/50">
<code className="text-cyan-400 text-lg font-mono">make dev-all</code>
@ -269,7 +271,7 @@ export function LandingPage() {
className="bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-white font-semibold px-8 py-6 text-lg rounded-xl shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 transition-all duration-300 hover:scale-105"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}
>
{t('landing.ctaButton')}
</Button>
</div>
</div>
@ -282,9 +284,9 @@ export function LandingPage() {
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-slate-400 text-sm">
<div>© 2026 SkillHub. MIT License.</div>
<div className="flex items-center gap-6">
<a href="#" className="hover:text-cyan-400 transition-colors"></a>
<a href="#" className="hover:text-cyan-400 transition-colors">GitHub</a>
<a href="#" className="hover:text-cyan-400 transition-colors"></a>
<a href="#" className="hover:text-cyan-400 transition-colors">{t('landing.footerDocs')}</a>
<a href="#" className="hover:text-cyan-400 transition-colors">{t('landing.footerGithub')}</a>
<a href="#" className="hover:text-cyan-400 transition-colors">{t('landing.footerCommunity')}</a>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoginButton } from '@/features/auth/login-button'
import { useLocalLogin } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
@ -7,6 +8,7 @@ import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
export function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/login' })
const loginMutation = useLocalLogin()
@ -32,57 +34,57 @@ export function LoginPage() {
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary/70 items-center justify-center shadow-glow mb-4">
<span className="text-primary-foreground font-bold text-2xl">S</span>
</div>
<h1 className="text-4xl font-bold font-heading text-foreground"> SkillHub</h1>
<h1 className="text-4xl font-bold font-heading text-foreground">{t('login.title')}</h1>
<p className="text-muted-foreground text-lg">
{t('login.subtitle')}
</p>
</div>
<div className="glass-strong p-8 rounded-2xl">
<Tabs defaultValue="password" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password"></TabsTrigger>
<TabsTrigger value="oauth">GitHub</TabsTrigger>
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>
<TabsTrigger value="oauth">{t('login.tabOAuth')}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="username"></label>
<label className="text-sm font-medium" htmlFor="username">{t('login.username')}</label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="输入用户名"
placeholder={t('login.usernamePlaceholder')}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password"></label>
<label className="text-sm font-medium" htmlFor="password">{t('login.password')}</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
placeholder={t('login.passwordPlaceholder')}
/>
</div>
{loginMutation.error ? (
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={loginMutation.isPending} type="submit">
{loginMutation.isPending ? '登录中...' : '登录'}
{loginMutation.isPending ? t('login.submitting') : t('login.submit')}
</Button>
<p className="text-center text-sm text-muted-foreground">
{t('login.noAccount')}
{' '}
<Link
to="/register"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('login.register')}
</Link>
</p>
</form>
@ -90,7 +92,7 @@ export function LoginPage() {
<TabsContent value="oauth" className="space-y-4">
<p className="text-sm text-muted-foreground">
使 GitHub
{t('login.oauthHint')}
</p>
<LoginButton returnTo={returnTo} />
</TabsContent>
@ -98,10 +100,10 @@ export function LoginPage() {
</div>
<p className="text-center text-xs text-muted-foreground">
<a href="#" className="text-primary hover:underline ml-1"></a>
<a href="#" className="text-primary hover:underline ml-1"></a>
{t('login.agreementPrefix')}
<a href="#" className="text-primary hover:underline ml-1">{t('login.terms')}</a>
{t('login.and')}
<a href="#" className="text-primary hover:underline ml-1">{t('login.privacy')}</a>
</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useNavigate, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
@ -6,6 +7,7 @@ import { EmptyState } from '@/shared/components/empty-state'
import { useNamespaceDetail, useSearchSkills } from '@/shared/hooks/use-skill-queries'
export function NamespacePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { namespace } = useParams({ from: '/space/$namespace' })
@ -29,7 +31,7 @@ export function NamespacePage() {
}
if (!namespaceData) {
return <EmptyState title="命名空间不存在" />
return <EmptyState title={t('namespace.notFound')} />
}
return (
@ -37,7 +39,7 @@ export function NamespacePage() {
<NamespaceHeader namespace={namespaceData} />
<div className="space-y-6">
<h2 className="text-2xl font-bold font-heading"></h2>
<h2 className="text-2xl font-bold font-heading">{t('namespace.skillList')}</h2>
{isLoadingSkills ? (
<SkeletonList count={6} />
) : skillsData && skillsData.items.length > 0 ? (
@ -53,8 +55,8 @@ export function NamespacePage() {
</div>
) : (
<EmptyState
title="暂无技能"
description="该命名空间下还没有发布任何技能"
title={t('namespace.emptyTitle')}
description={t('namespace.emptyDescription')}
/>
)}
</div>

View file

@ -1,5 +1,6 @@
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoginButton } from '@/features/auth/login-button'
import { useLocalRegister } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
@ -8,6 +9,7 @@ import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
export function RegisterPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/register' })
const registerMutation = useLocalRegister()
@ -31,65 +33,65 @@ export function RegisterPage() {
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle></CardTitle>
<CardDescription>使 OAuth </CardDescription>
<CardTitle>{t('register.title')}</CardTitle>
<CardDescription>{t('register.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="local" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="local"></TabsTrigger>
<TabsTrigger value="oauth">OAuth</TabsTrigger>
<TabsTrigger value="local">{t('register.tabLocal')}</TabsTrigger>
<TabsTrigger value="oauth">{t('register.tabOAuth')}</TabsTrigger>
</TabsList>
<TabsContent value="local">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-username"></label>
<label className="text-sm font-medium" htmlFor="register-username">{t('register.username')}</label>
<Input
id="register-username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-64 位字母、数字或下划线"
placeholder={t('register.usernamePlaceholder')}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-email"></label>
<label className="text-sm font-medium" htmlFor="register-email">{t('register.email')}</label>
<Input
id="register-email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="可选,用于后续账号识别"
placeholder={t('register.emailPlaceholder')}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-password"></label>
<label className="text-sm font-medium" htmlFor="register-password">{t('register.password')}</label>
<Input
id="register-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位,包含 3 种字符类型"
placeholder={t('register.passwordPlaceholder')}
/>
</div>
{registerMutation.error ? (
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
{registerMutation.isPending ? '注册中...' : '注册并登录'}
{registerMutation.isPending ? t('register.submitting') : t('register.submit')}
</Button>
<p className="text-center text-sm text-muted-foreground">
{t('register.hasAccount')}
{' '}
<Link
to="/login"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('register.login')}
</Link>
</p>
</form>
@ -97,7 +99,7 @@ export function RegisterPage() {
<TabsContent value="oauth" className="space-y-4">
<p className="text-sm text-muted-foreground">
使 OAuth
{t('register.oauthHint')}
</p>
<LoginButton returnTo={returnTo} />
</TabsContent>

View file

@ -1,4 +1,5 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SearchBar } from '@/features/search/search-bar'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
@ -8,6 +9,7 @@ import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { Button } from '@/shared/ui/button'
export function SearchPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const searchParams = useSearch({ from: '/search' })
@ -50,35 +52,35 @@ export function SearchPage() {
{/* Sort Selector */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">:</span>
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
<div className="flex gap-2">
<Button
variant={sort === 'relevance' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('relevance')}
>
{t('search.sort.relevance')}
</Button>
<Button
variant={sort === 'downloads' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('downloads')}
>
{t('search.sort.downloads')}
</Button>
<Button
variant={sort === 'newest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('newest')}
>
{t('search.sort.newest')}
</Button>
</div>
</div>
{data && data.total > 0 && (
<div className="text-sm text-muted-foreground">
<span className="text-primary font-semibold">{data.total}</span>
{t('search.results', { count: data.total })}
</div>
)}
</div>
@ -108,8 +110,8 @@ export function SearchPage() {
</>
) : (
<EmptyState
title="未找到结果"
description={q ? `没有找到与 "${q}" 相关的技能` : '请输入搜索关键词'}
title={t('search.noResults')}
description={q ? t('search.noResultsFor', { q }) : t('search.enterKeyword')}
/>
)}
</div>

View file

@ -1,10 +1,12 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function AccountSettingsPage() {
const { t } = useTranslation()
const [secondaryIdentifier, setSecondaryIdentifier] = useState('')
const [mergeRequestId, setMergeRequestId] = useState('')
const [verificationToken, setVerificationToken] = useState('')
@ -21,9 +23,9 @@ export function AccountSettingsPage() {
const result = await initiateMutation.mutateAsync({ secondaryIdentifier })
setMergeRequestId(String(result.mergeRequestId))
setVerificationToken(result.verificationToken)
setStatusMessage(`已创建合并请求secondary=${result.secondaryUserId}`)
setStatusMessage(t('accounts.initiateSuccess', { secondaryUserId: result.secondaryUserId }))
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : '发起合并失败')
setStatusMessage(error instanceof Error ? error.message : t('accounts.initiateError'))
}
}
@ -35,9 +37,9 @@ export function AccountSettingsPage() {
mergeRequestId: Number(mergeRequestId),
verificationToken,
})
setStatusMessage('验证成功,确认后将执行正式合并')
setStatusMessage(t('accounts.verifySuccess'))
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : '验证合并失败')
setStatusMessage(error instanceof Error ? error.message : t('accounts.verifyError'))
}
}
@ -45,9 +47,9 @@ export function AccountSettingsPage() {
setStatusMessage('')
try {
await confirmMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) })
setStatusMessage('账号合并已完成')
setStatusMessage(t('accounts.confirmSuccess'))
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : '确认合并失败')
setStatusMessage(error instanceof Error ? error.message : t('accounts.confirmError'))
}
}
@ -55,22 +57,22 @@ export function AccountSettingsPage() {
<div className="mx-auto max-w-3xl space-y-6">
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> secondary `provider:subject` </CardDescription>
<CardTitle>{t('accounts.initiateTitle')}</CardTitle>
<CardDescription>{t('accounts.initiateDesc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleInitiate}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="secondary-identifier">Secondary </label>
<label className="text-sm font-medium" htmlFor="secondary-identifier">{t('accounts.secondaryLabel')}</label>
<Input
id="secondary-identifier"
value={secondaryIdentifier}
onChange={(event) => setSecondaryIdentifier(event.target.value)}
placeholder="例如other_user 或 github:123456"
placeholder={t('accounts.secondaryPlaceholder')}
/>
</div>
<Button type="submit" disabled={initiateMutation.isPending}>
{initiateMutation.isPending ? '发起中...' : '发起合并'}
{initiateMutation.isPending ? t('accounts.initiating') : t('accounts.initiate')}
</Button>
</form>
</CardContent>
@ -78,13 +80,13 @@ export function AccountSettingsPage() {
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> token </CardDescription>
<CardTitle>{t('accounts.verifyTitle')}</CardTitle>
<CardDescription>{t('accounts.verifyDesc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleVerify}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="merge-request-id">Merge Request ID</label>
<label className="text-sm font-medium" htmlFor="merge-request-id">{t('accounts.mergeRequestId')}</label>
<Input
id="merge-request-id"
value={mergeRequestId}
@ -92,7 +94,7 @@ export function AccountSettingsPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="merge-token">Verification Token</label>
<label className="text-sm font-medium" htmlFor="merge-token">{t('accounts.verificationToken')}</label>
<Input
id="merge-token"
value={verificationToken}
@ -100,12 +102,12 @@ export function AccountSettingsPage() {
/>
</div>
<Button type="submit" disabled={verifyMutation.isPending}>
{verifyMutation.isPending ? '验证中...' : '完成合并'}
{verifyMutation.isPending ? t('accounts.verifying') : t('accounts.verify')}
</Button>
</form>
<div className="mt-4">
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !mergeRequestId}>
{confirmMutation.isPending ? '确认中...' : '确认并完成合并'}
{confirmMutation.isPending ? t('accounts.confirming') : t('accounts.confirm')}
</Button>
</div>
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}

View file

@ -1,10 +1,12 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { authApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function SecuritySettingsPage() {
const { t } = useTranslation()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [statusMessage, setStatusMessage] = useState('')
@ -18,11 +20,11 @@ export function SecuritySettingsPage() {
setIsSubmitting(true)
try {
await authApi.changePassword({ currentPassword, newPassword })
setStatusMessage('密码修改成功')
setStatusMessage(t('security.success'))
setCurrentPassword('')
setNewPassword('')
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '修改密码失败')
setErrorMessage(error instanceof Error ? error.message : t('security.defaultError'))
} finally {
setIsSubmitting(false)
}
@ -32,13 +34,13 @@ export function SecuritySettingsPage() {
<div className="mx-auto max-w-2xl">
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle>{t('security.title')}</CardTitle>
<CardDescription>{t('security.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="current-password"></label>
<label className="text-sm font-medium" htmlFor="current-password">{t('security.currentPassword')}</label>
<Input
id="current-password"
type="password"
@ -48,7 +50,7 @@ export function SecuritySettingsPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-password"></label>
<label className="text-sm font-medium" htmlFor="new-password">{t('security.newPassword')}</label>
<Input
id="new-password"
type="password"
@ -60,7 +62,7 @@ export function SecuritySettingsPage() {
{statusMessage ? <p className="text-sm text-emerald-600">{statusMessage}</p> : null}
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '更新密码'}
{isSubmitting ? t('security.submitting') : t('security.submit')}
</Button>
</form>
</CardContent>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
@ -19,6 +20,7 @@ import {
} from '@/shared/hooks/use-skill-queries'
export function SkillDetailPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const location = useRouterState({ select: (s) => s.location })
const queryClient = useQueryClient()
@ -84,8 +86,8 @@ export function SkillDetailPage() {
if (!skill) {
return (
<div className="text-center py-20 animate-fade-up">
<h2 className="text-2xl font-bold font-heading mb-2"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold font-heading mb-2">{t('skillDetail.notFound')}</h2>
<p className="text-muted-foreground">{t('skillDetail.notFoundDesc')}</p>
</div>
)
}
@ -106,9 +108,9 @@ export function SkillDetailPage() {
<Tabs defaultValue="readme">
<TabsList>
<TabsTrigger value="readme">README</TabsTrigger>
<TabsTrigger value="files"></TabsTrigger>
<TabsTrigger value="versions"></TabsTrigger>
<TabsTrigger value="readme">{t('skillDetail.tabReadme')}</TabsTrigger>
<TabsTrigger value="files">{t('skillDetail.tabFiles')}</TabsTrigger>
<TabsTrigger value="versions">{t('skillDetail.tabVersions')}</TabsTrigger>
</TabsList>
<TabsContent value="readme" className="mt-6">
@ -118,7 +120,7 @@ export function SkillDetailPage() {
</Card>
) : (
<Card className="p-8 text-muted-foreground text-center">
README
{t('skillDetail.noReadme')}
</Card>
)}
</TabsContent>
@ -128,7 +130,7 @@ export function SkillDetailPage() {
<FileTree files={files} />
) : (
<Card className="p-8 text-muted-foreground text-center">
{t('skillDetail.noFiles')}
</Card>
)}
</TabsContent>
@ -146,14 +148,14 @@ export function SkillDetailPage() {
</span>
</span>
<span className="text-sm text-muted-foreground">
{new Date(version.publishedAt).toLocaleDateString('zh-CN')}
{new Date(version.publishedAt).toLocaleDateString(i18n.language)}
</span>
</div>
{version.changelog && (
<p className="text-sm text-muted-foreground leading-relaxed">{version.changelog}</p>
)}
<div className="text-xs text-muted-foreground mt-2 flex items-center gap-3">
<span>{version.fileCount} </span>
<span>{t('skillDetail.fileCount', { count: version.fileCount })}</span>
<span className="w-1 h-1 rounded-full bg-muted-foreground/40" />
<span>{(version.totalSize / 1024).toFixed(1)} KB</span>
</div>
@ -161,7 +163,7 @@ export function SkillDetailPage() {
))}
</div>
) : (
<div className="text-muted-foreground text-center py-8"></div>
<div className="text-muted-foreground text-center py-8">{t('skillDetail.noVersions')}</div>
)}
</Card>
</TabsContent>
@ -172,7 +174,7 @@ export function SkillDetailPage() {
<div className="space-y-5">
<Card className="p-5 space-y-5">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
<div className="font-semibold font-mono text-foreground">
{skill.latestVersion ? `v${skill.latestVersion}` : '—'}
</div>
@ -181,23 +183,23 @@ export function SkillDetailPage() {
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('skillDetail.downloads')}</div>
<div className="font-semibold text-foreground">{skill.downloadCount.toLocaleString()}</div>
</div>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('skillDetail.rating')}</div>
<div className="font-semibold text-foreground">
{skill.ratingCount > 0 && skill.ratingAvg !== undefined ? `${skill.ratingAvg.toFixed(1)} / 5` : '暂无'}
{skill.ratingCount > 0 && skill.ratingAvg !== undefined ? `${skill.ratingAvg.toFixed(1)} / 5` : t('skillDetail.ratingNone')}
</div>
</div>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t('skillDetail.namespaceLabel')}</div>
<NamespaceBadge type="GLOBAL" name={namespace} />
</div>
@ -207,14 +209,14 @@ export function SkillDetailPage() {
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
{!user && (
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('skillDetail.loginToRate')}</p>
)}
</div>
</Card>
{skill.latestVersion && (
<Card className="p-5 space-y-4">
<div className="text-sm font-semibold font-heading text-foreground"></div>
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
<InstallCommand
namespace={namespace}
slug={slug}
@ -233,25 +235,25 @@ export function SkillDetailPage() {
<svg className="w-4 h-4 mr-2" 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.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{t('skillDetail.download')}
</Button>
{governanceVisible && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground"></div>
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
<div className="flex flex-col gap-3">
{!skill.hidden ? (
<Button variant="outline" onClick={() => hideMutation.mutate()} disabled={hideMutation.isPending}>
{hideMutation.isPending ? '处理中...' : '隐藏技能'}
{hideMutation.isPending ? t('skillDetail.processing') : t('skillDetail.hideSkill')}
</Button>
) : (
<Button variant="outline" onClick={() => unhideMutation.mutate()} disabled={unhideMutation.isPending}>
{unhideMutation.isPending ? '处理中...' : '恢复技能'}
{unhideMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unhideSkill')}
</Button>
)}
{latestVersion && (
<Button variant="destructive" onClick={() => yankMutation.mutate()} disabled={yankMutation.isPending}>
{yankMutation.isPending ? '处理中...' : '撤回当前版本'}
{yankMutation.isPending ? t('skillDetail.processing') : t('skillDetail.yankVersion')}
</Button>
)}
</div>

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
interface CopyButtonProps {
@ -7,6 +8,7 @@ interface CopyButtonProps {
}
export function CopyButton({ text, className }: CopyButtonProps) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
@ -26,7 +28,7 @@ export function CopyButton({ text, className }: CopyButtonProps) {
onClick={handleCopy}
className={className}
>
{copied ? '已复制' : '复制'}
{copied ? t('copyButton.copied') : t('copyButton.copy')}
</Button>
)
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
interface PaginationProps {
@ -7,6 +8,7 @@ interface PaginationProps {
}
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-center gap-3 py-4">
<Button
@ -16,14 +18,14 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
disabled={page <= 0}
className="min-w-[90px]"
>
{t('pagination.prev')}
</Button>
<div className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-secondary/40 text-sm font-medium text-foreground">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t('pagination.pagePrefix')}</span>
<span className="text-primary">{page + 1}</span>
<span className="text-muted-foreground">/</span>
<span>{totalPages}</span>
<span className="text-muted-foreground"></span>
{t('pagination.pageSuffix') && <span className="text-muted-foreground">{t('pagination.pageSuffix')}</span>}
</div>
<Button
variant="outline"
@ -32,7 +34,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
disabled={page >= totalPages - 1}
className="min-w-[90px]"
>
{t('pagination.next')}
</Button>
</div>
)

View file

@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import {
DropdownMenu,
DropdownMenuContent,
@ -21,6 +23,7 @@ interface UserMenuProps {
export function UserMenu({ user }: UserMenuProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN')
@ -30,9 +33,9 @@ export function UserMenu({ user }: UserMenuProps) {
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
await authApi.logout()
queryClient.setQueryData(['auth', 'me'], null)
navigate({ to: '/' })
window.location.reload()
} catch (error) {
console.error('Logout failed:', error)
}