mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat(web): add review center, social features, and device auth page
This commit is contained in:
parent
fae775eb97
commit
ddb940d5d5
11 changed files with 778 additions and 0 deletions
|
|
@ -10,6 +10,9 @@ import { PublishPage } from '@/pages/dashboard/publish'
|
|||
import { MySkillsPage } from '@/pages/dashboard/my-skills'
|
||||
import { MyNamespacesPage } from '@/pages/dashboard/my-namespaces'
|
||||
import { NamespaceMembersPage } from '@/pages/dashboard/namespace-members'
|
||||
import { ReviewsPage } from '@/pages/dashboard/reviews'
|
||||
import { ReviewDetailPage } from '@/pages/dashboard/review-detail'
|
||||
import { DeviceAuthPage } from '@/pages/device'
|
||||
import { getCurrentUser } from '@/api/client'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
@ -118,6 +121,38 @@ const dashboardNamespaceMembersRoute = createRoute({
|
|||
component: NamespaceMembersPage,
|
||||
})
|
||||
|
||||
const dashboardReviewsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard/reviews',
|
||||
beforeLoad: async () => {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
const dashboardReviewDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard/reviews/$id',
|
||||
beforeLoad: async () => {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: ReviewDetailPage,
|
||||
})
|
||||
|
||||
const deviceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/device',
|
||||
component: DeviceAuthPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
loginRoute,
|
||||
|
|
@ -129,6 +164,9 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardPublishRoute,
|
||||
dashboardNamespacesRoute,
|
||||
dashboardNamespaceMembersRoute,
|
||||
dashboardReviewsRoute,
|
||||
dashboardReviewDetailRoute,
|
||||
deviceRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
|
|
|||
59
web/src/features/review/use-review-detail.ts
Normal file
59
web/src/features/review/use-review-detail.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
import type { ReviewTask } from './use-review-list'
|
||||
|
||||
async function getReviewDetail(taskId: number): Promise<ReviewTask> {
|
||||
return fetchJson<ReviewTask>(`/api/v1/reviews/${taskId}`)
|
||||
}
|
||||
|
||||
async function approveReview(taskId: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/reviews/${taskId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
}
|
||||
|
||||
async function rejectReview(taskId: number, comment: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/reviews/${taskId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useReviewDetail(taskId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', taskId],
|
||||
queryFn: () => getReviewDetail(taskId),
|
||||
enabled: !!taskId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveReview() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, comment }: { taskId: number; comment?: string }) =>
|
||||
approveReview(taskId, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reviews'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRejectReview() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, comment }: { taskId: number; comment: string }) =>
|
||||
rejectReview(taskId, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reviews'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
29
web/src/features/review/use-review-list.ts
Normal file
29
web/src/features/review/use-review-list.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchJson } from '@/api/client'
|
||||
|
||||
export interface ReviewTask {
|
||||
id: number
|
||||
skillVersionId: number
|
||||
skillName: string
|
||||
skillSlug: string
|
||||
namespace: string
|
||||
version: string
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
submittedBy: string
|
||||
submittedAt: string
|
||||
reviewedBy?: string
|
||||
reviewedAt?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
async function getReviewList(status?: string): Promise<ReviewTask[]> {
|
||||
const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews'
|
||||
return fetchJson<ReviewTask[]>(url)
|
||||
}
|
||||
|
||||
export function useReviewList(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', status],
|
||||
queryFn: () => getReviewList(status),
|
||||
})
|
||||
}
|
||||
57
web/src/features/social/rating-input.tsx
Normal file
57
web/src/features/social/rating-input.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from 'react'
|
||||
import { Star } from 'lucide-react'
|
||||
import { useUserRating, useRate } from './use-rating'
|
||||
|
||||
interface RatingInputProps {
|
||||
skillId: number
|
||||
}
|
||||
|
||||
export function RatingInput({ skillId }: RatingInputProps) {
|
||||
const { data: userRating, isLoading } = useUserRating(skillId)
|
||||
const rateMutation = useRate(skillId)
|
||||
const [hoveredRating, setHoveredRating] = useState<number | null>(null)
|
||||
|
||||
const currentRating = userRating?.rating || 0
|
||||
|
||||
const handleRate = (rating: number) => {
|
||||
rateMutation.mutate(rating)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((rating) => {
|
||||
const isFilled = rating <= (hoveredRating || currentRating)
|
||||
return (
|
||||
<button
|
||||
key={rating}
|
||||
type="button"
|
||||
className="p-1 hover:scale-110 transition-transform"
|
||||
onMouseEnter={() => setHoveredRating(rating)}
|
||||
onMouseLeave={() => setHoveredRating(null)}
|
||||
onClick={() => handleRate(rating)}
|
||||
disabled={rateMutation.isPending}
|
||||
>
|
||||
<Star
|
||||
className={`w-5 h-5 ${
|
||||
isFilled
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{currentRating > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
你的评分: {currentRating} 星
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
web/src/features/social/star-button.tsx
Normal file
34
web/src/features/social/star-button.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Button } from '@/shared/ui/button'
|
||||
import { useStar, useToggleStar } from './use-star'
|
||||
import { Star } from 'lucide-react'
|
||||
|
||||
interface StarButtonProps {
|
||||
skillId: number
|
||||
}
|
||||
|
||||
export function StarButton({ skillId }: StarButtonProps) {
|
||||
const { data: starStatus, isLoading } = useStar(skillId)
|
||||
const toggleMutation = useToggleStar(skillId)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (starStatus) {
|
||||
toggleMutation.mutate(starStatus.starred)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !starStatus) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={starStatus.starred ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
|
||||
{starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount})
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
41
web/src/features/social/use-rating.ts
Normal file
41
web/src/features/social/use-rating.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
|
||||
interface UserRating {
|
||||
rating?: number
|
||||
ratedAt?: string
|
||||
}
|
||||
|
||||
async function getUserRating(skillId: number): Promise<UserRating> {
|
||||
return fetchJson<UserRating>(`/api/v1/skills/${skillId}/rating`)
|
||||
}
|
||||
|
||||
async function rateSkill(skillId: number, rating: number): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/rating`, {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ rating }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUserRating(skillId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', skillId, 'rating'],
|
||||
queryFn: () => getUserRating(skillId),
|
||||
enabled: !!skillId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRate(skillId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (rating: number) => rateSkill(skillId, rating),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'rating'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
45
web/src/features/social/use-star.ts
Normal file
45
web/src/features/social/use-star.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
|
||||
interface StarStatus {
|
||||
starred: boolean
|
||||
starCount: number
|
||||
}
|
||||
|
||||
async function getStarStatus(skillId: number): Promise<StarStatus> {
|
||||
return fetchJson<StarStatus>(`/api/v1/skills/${skillId}/star`)
|
||||
}
|
||||
|
||||
async function toggleStar(skillId: number, starred: boolean): Promise<void> {
|
||||
if (starred) {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
} else {
|
||||
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useStar(skillId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', skillId, 'star'],
|
||||
queryFn: () => getStarStatus(skillId),
|
||||
enabled: !!skillId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleStar(skillId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (starred: boolean) => toggleStar(skillId, starred),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
186
web/src/pages/dashboard/review-detail.tsx
Normal file
186
web/src/pages/dashboard/review-detail.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail'
|
||||
|
||||
export function ReviewDetailPage() {
|
||||
const { id } = useParams({ from: '/dashboard/reviews/$id' })
|
||||
const navigate = useNavigate()
|
||||
const taskId = Number(id)
|
||||
|
||||
const { data: review, isLoading } = useReviewDetail(taskId)
|
||||
const approveMutation = useApproveReview()
|
||||
const rejectMutation = useRejectReview()
|
||||
|
||||
const [comment, setComment] = useState('')
|
||||
const [showRejectForm, setShowRejectForm] = useState(false)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const handleApprove = () => {
|
||||
if (window.confirm('确定要通过这个审核吗?')) {
|
||||
approveMutation.mutate(
|
||||
{ taskId, comment: comment || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({ to: '/dashboard/reviews' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
if (!comment.trim()) {
|
||||
alert('拒绝审核时必须填写原因')
|
||||
return
|
||||
}
|
||||
if (window.confirm('确定要拒绝这个审核吗?')) {
|
||||
rejectMutation.mutate(
|
||||
{ taskId, comment },
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({ to: '/dashboard/reviews' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return <div className="text-center py-8 text-muted-foreground">审核任务不存在</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">审核详情</h1>
|
||||
<p className="text-muted-foreground">审核 ID: {review.id}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate({ to: '/dashboard/reviews' })}>
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">技能名称</Label>
|
||||
<p className="font-medium">{review.skillName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">命名空间/标识</Label>
|
||||
<p className="font-medium">{review.namespace}/{review.skillSlug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">版本</Label>
|
||||
<p className="font-medium">{review.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">状态</Label>
|
||||
<p className="font-medium">
|
||||
{review.status === 'PENDING' && '待审核'}
|
||||
{review.status === 'APPROVED' && '已通过'}
|
||||
{review.status === 'REJECTED' && '已拒绝'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">提交者</Label>
|
||||
<p className="font-medium">{review.submittedBy}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">提交时间</Label>
|
||||
<p className="font-medium">{formatDate(review.submittedAt)}</p>
|
||||
</div>
|
||||
{review.reviewedBy && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核者</Label>
|
||||
<p className="font-medium">{review.reviewedBy}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核时间</Label>
|
||||
<p className="font-medium">
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核意见</Label>
|
||||
<p className="mt-2 p-3 bg-muted rounded-md">{review.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{review.status === 'PENDING' && (
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">审核操作</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">审核意见(可选)</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder="填写审核意见..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
通过审核
|
||||
</Button>
|
||||
{!showRejectForm ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
拒绝审核
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReject}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending || !comment.trim()}
|
||||
>
|
||||
确认拒绝
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRejectForm(false)}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRejectForm && !comment.trim() && (
|
||||
<p className="text-sm text-destructive">拒绝审核时必须填写原因</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/dashboard/reviews.tsx
Normal file
111
web/src/pages/dashboard/reviews.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
|
||||
export function ReviewsPage() {
|
||||
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')
|
||||
}
|
||||
|
||||
const handleRowClick = (reviewId: number) => {
|
||||
navigate({ to: '/dashboard/reviews/$id', params: { id: String(reviewId) } })
|
||||
}
|
||||
|
||||
const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => {
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
}
|
||||
|
||||
if (!reviews || reviews.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-muted-foreground">暂无审核任务</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>技能</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>提交者</TableHead>
|
||||
<TableHead>提交时间</TableHead>
|
||||
{status !== 'PENDING' && <TableHead>审核者</TableHead>}
|
||||
{status !== 'PENDING' && <TableHead>审核时间</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reviews.map((review) => (
|
||||
<TableRow
|
||||
key={review.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(review.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{review.namespace}/{review.skillSlug}
|
||||
</TableCell>
|
||||
<TableCell>{review.version}</TableCell>
|
||||
<TableCell>{review.submittedBy}</TableCell>
|
||||
<TableCell>{formatDate(review.submittedAt)}</TableCell>
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell>{review.reviewedBy || '-'}</TableCell>
|
||||
)}
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell>
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">审核中心</h1>
|
||||
<p className="text-muted-foreground">管理技能版本审核</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="PENDING">
|
||||
<TabsList>
|
||||
<TabsTrigger value="PENDING">待审核</TabsTrigger>
|
||||
<TabsTrigger value="APPROVED">已通过</TabsTrigger>
|
||||
<TabsTrigger value="REJECTED">已拒绝</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="PENDING" className="mt-4">
|
||||
{renderReviewTable(pendingReviews, isPendingLoading, 'PENDING')}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="APPROVED" className="mt-4">
|
||||
{renderReviewTable(approvedReviews, isApprovedLoading, 'APPROVED')}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="REJECTED" className="mt-4">
|
||||
{renderReviewTable(rejectedReviews, isRejectedLoading, 'REJECTED')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
web/src/pages/device.tsx
Normal file
156
web/src/pages/device.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { fetchJson, getCsrfHeaders } from '@/api/client'
|
||||
|
||||
async function authorizeDevice(userCode: string): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/device/authorize', {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ userCode }),
|
||||
})
|
||||
}
|
||||
|
||||
export function DeviceAuthPage() {
|
||||
const [part1, setPart1] = useState('')
|
||||
const [part2, setPart2] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const input1Ref = useRef<HTMLInputElement>(null)
|
||||
const input2Ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
input1Ref.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handlePart1Change = (value: string) => {
|
||||
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4)
|
||||
setPart1(cleaned)
|
||||
if (cleaned.length === 4) {
|
||||
input2Ref.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePart2Change = (value: string) => {
|
||||
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4)
|
||||
setPart2(cleaned)
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const pasted = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
|
||||
if (pasted.length >= 8) {
|
||||
setPart1(pasted.slice(0, 4))
|
||||
setPart2(pasted.slice(4, 8))
|
||||
input2Ref.current?.focus()
|
||||
} else if (pasted.length > 0) {
|
||||
setPart1(pasted.slice(0, 4))
|
||||
if (pasted.length > 4) {
|
||||
setPart2(pasted.slice(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (part1.length !== 4 || part2.length !== 4) {
|
||||
setMessage({ type: 'error', text: '请输入完整的 8 位用户码' })
|
||||
return
|
||||
}
|
||||
|
||||
const userCode = `${part1}-${part2}`
|
||||
setIsSubmitting(true)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
await authorizeDevice(userCode)
|
||||
setMessage({ type: 'success', text: '设备授权成功!' })
|
||||
setPart1('')
|
||||
setPart2('')
|
||||
input1Ref.current?.focus()
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : '授权失败,请检查用户码是否正确'
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-8 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold">设备授权</h1>
|
||||
<p className="text-muted-foreground">
|
||||
请输入设备上显示的 8 位用户码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>用户码</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
ref={input1Ref}
|
||||
type="text"
|
||||
value={part1}
|
||||
onChange={(e) => handlePart1Change(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
placeholder="XXXX"
|
||||
className="text-center text-2xl font-mono tracking-wider"
|
||||
maxLength={4}
|
||||
/>
|
||||
<span className="text-2xl font-bold text-muted-foreground">-</span>
|
||||
<Input
|
||||
ref={input2Ref}
|
||||
type="text"
|
||||
value={part2}
|
||||
onChange={(e) => handlePart2Change(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
placeholder="XXXX"
|
||||
className="text-center text-2xl font-mono tracking-wider"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
格式: XXXX-XXXX (支持粘贴)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-md ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || part1.length !== 4 || part2.length !== 4}
|
||||
>
|
||||
{isSubmitting ? '授权中...' : '授权设备'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>授权后,设备将可以访问你的账户</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
web/src/shared/ui/textarea.tsx
Normal file
22
web/src/shared/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
Loading…
Add table
Reference in a new issue