Merge pull request #67 from iflytek/feature/ui

Feature/UI
This commit is contained in:
wowo 2026-03-17 18:58:58 +08:00 committed by GitHub
commit b8f1e5cb75
4 changed files with 180 additions and 124 deletions

View file

@ -22,7 +22,7 @@ export function useReviewDetail(taskId: number) {
})
}
export function useApproveReview() {
export function useApproveReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) {
const queryClient = useQueryClient()
return useMutation({
@ -31,11 +31,13 @@ export function useApproveReview() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
callbacks?.onSuccess?.()
},
onError: callbacks?.onError,
})
}
export function useRejectReview() {
export function useRejectReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) {
const queryClient = useQueryClient()
return useMutation({
@ -44,6 +46,8 @@ export function useRejectReview() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
callbacks?.onSuccess?.()
},
onError: callbacks?.onError,
})
}

View file

@ -17,8 +17,24 @@ export function ReviewDetailPage() {
const taskId = Number(id)
const { data: review, isLoading } = useReviewDetail(taskId)
const approveMutation = useApproveReview()
const rejectMutation = useRejectReview()
const approveMutation = useApproveReview({
onSuccess: () => {
toast.success(t('review.approveSuccess'))
navigate({ to: '/dashboard/reviews' })
},
onError: (error) => {
toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error))
},
})
const rejectMutation = useRejectReview({
onSuccess: () => {
toast.success(t('review.rejectSuccess'))
navigate({ to: '/dashboard/reviews' })
},
onError: (error) => {
toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error))
},
})
const [comment, setComment] = useState('')
const [showRejectForm, setShowRejectForm] = useState(false)
@ -30,18 +46,7 @@ export function ReviewDetailPage() {
}
const handleApprove = async () => {
approveMutation.mutate(
{ taskId, comment: comment || undefined },
{
onSuccess: () => {
toast.success(t('review.approveSuccess'))
navigate({ to: '/dashboard/reviews' })
},
onError: (error) => {
toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error))
},
}
)
approveMutation.mutate({ taskId, comment: comment || undefined })
}
const handleReject = async () => {
@ -49,18 +54,7 @@ export function ReviewDetailPage() {
toast.error(t('review.rejectReasonRequired'))
return
}
rejectMutation.mutate(
{ taskId, comment },
{
onSuccess: () => {
toast.success(t('review.rejectSuccess'))
navigate({ to: '/dashboard/reviews' })
},
onError: (error) => {
toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error))
},
}
)
rejectMutation.mutate({ taskId, comment })
}
if (isLoading) {

View file

@ -7,7 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/shared/ui/dropdown-menu'
import { Globe } from 'lucide-react'
import { ChevronDown, Globe } from 'lucide-react'
interface LanguageSwitcherProps {
className?: string
@ -35,18 +35,22 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
<Button
variant="ghost"
size="sm"
className={cn('gap-2 text-muted-foreground hover:text-foreground', className)}
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
>
<Globe className="h-4 w-4" />
<span className="text-sm text-inherit">{currentLanguage.name}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="flex min-w-[9rem] flex-col gap-1.5 p-2">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={currentLangCode === lang.code ? 'bg-accent' : ''}
className={cn(
'cursor-pointer rounded-md px-3 py-2',
currentLangCode === lang.code ? 'bg-accent' : ''
)}
>
{lang.name}
</DropdownMenuItem>

View file

@ -1,15 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import { cn } from '@/shared/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/shared/ui/dropdown-menu'
interface User {
displayName: string
@ -25,12 +19,48 @@ interface UserMenuProps {
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const rootRef = useRef<HTMLDivElement | null>(null)
const closeTimerRef = useRef<number | null>(null)
const [isHovered, setIsHovered] = useState(false)
const [isClickOpen, setIsClickOpen] = useState(false)
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN')
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
const open = isHovered || isClickOpen
const clearCloseTimer = () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
useEffect(() => {
if (!open) {
return
}
const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsHovered(false)
setIsClickOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
}
}, [open])
useEffect(() => {
return () => {
clearCloseTimer()
}
}, [])
const handleLogout = async () => {
try {
@ -44,96 +74,120 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
}
}
const closeMenu = () => {
clearCloseTimer()
setIsHovered(false)
setIsClickOpen(false)
}
const handleMouseEnter = () => {
clearCloseTimer()
setIsHovered(true)
}
const handleMouseLeave = () => {
clearCloseTimer()
closeTimerRef.current = window.setTimeout(() => {
setIsHovered(false)
closeTimerRef.current = null
}, 120)
}
const menuItemClassName =
'block w-full rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity', triggerClassName)}>
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
loading="lazy"
className="w-8 h-8 rounded-full border border-border/60"
/>
)}
<span className="text-sm font-medium text-inherit">
{user.displayName}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
<Link to="/dashboard" className="cursor-pointer">
{t('user.menu.dashboard')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/skills" className="cursor-pointer">
{t('user.menu.mySkills')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/namespaces" className="cursor-pointer">
{t('user.menu.myNamespaces')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/governance" className="cursor-pointer">
{t('user.menu.governance')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/stars" className="cursor-pointer">
{t('user.menu.stars')}
</Link>
</DropdownMenuItem>
{isReviewer && (
<DropdownMenuItem asChild>
<Link to="/dashboard/reviews" className="cursor-pointer">
{t('user.menu.reviews')}
</Link>
</DropdownMenuItem>
<div
ref={rootRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
aria-expanded={open}
aria-haspopup="menu"
className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity', triggerClassName)}
onClick={() => setIsClickOpen((current) => !current)}
>
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
loading="lazy"
className="w-8 h-8 rounded-full border border-border/60"
/>
)}
{isSkillAdmin && (
<DropdownMenuItem asChild>
<Link to="/dashboard/promotions" className="cursor-pointer">
{t('user.menu.promotions')}
<span className="text-sm font-medium text-inherit">
{user.displayName}
</span>
</button>
{open ? (
<div
className="absolute right-0 top-full z-50 w-48 pt-2"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
role="menu"
className="overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<Link to="/dashboard" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.dashboard')}
</Link>
</DropdownMenuItem>
)}
{isSkillAdmin && (
<DropdownMenuItem asChild>
<Link to="/dashboard/reports" className="cursor-pointer">
{t('user.menu.reports')}
<Link to="/dashboard/skills" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.mySkills')}
</Link>
</DropdownMenuItem>
)}
{(isUserAdmin || isAuditor) && <DropdownMenuSeparator />}
{isUserAdmin && (
<DropdownMenuItem asChild>
<Link to="/admin/users" className="cursor-pointer">
{t('user.menu.users')}
<Link to="/dashboard/namespaces" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.myNamespaces')}
</Link>
</DropdownMenuItem>
)}
{isAuditor && (
<DropdownMenuItem asChild>
<Link to="/admin/audit-log" className="cursor-pointer">
{t('user.menu.auditLog')}
<Link to="/dashboard/governance" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.governance')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings/security" className="cursor-pointer">
{t('user.menu.security')}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer text-destructive">
{t('user.menu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.stars')}
</Link>
{isReviewer ? (
<Link to="/dashboard/reviews" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviews')}
</Link>
) : null}
{isSkillAdmin ? (
<Link to="/dashboard/promotions" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.promotions')}
</Link>
) : null}
{isSkillAdmin ? (
<Link to="/dashboard/reports" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reports')}
</Link>
) : null}
{isUserAdmin || isAuditor ? <div className="-mx-1 my-1 h-px bg-muted" /> : null}
{isUserAdmin ? (
<Link to="/admin/users" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.users')}
</Link>
) : null}
{isAuditor ? (
<Link to="/admin/audit-log" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.auditLog')}
</Link>
) : null}
<div className="-mx-1 my-1 h-px bg-muted" />
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.security')}
</Link>
<div className="-mx-1 my-1 h-px bg-muted" />
<button
type="button"
onClick={handleLogout}
className={cn(menuItemClassName, 'text-destructive')}
>
{t('user.menu.logout')}
</button>
</div>
</div>
) : null}
</div>
)
}