mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
commit
b8f1e5cb75
4 changed files with 180 additions and 124 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue