From ddb940d5d5041293d8d43ae3ec7bd40e30e3afc1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 19:12:50 +0800 Subject: [PATCH] feat(web): add review center, social features, and device auth page --- web/src/app/router.tsx | 38 ++++ web/src/features/review/use-review-detail.ts | 59 ++++++ web/src/features/review/use-review-list.ts | 29 +++ web/src/features/social/rating-input.tsx | 57 ++++++ web/src/features/social/star-button.tsx | 34 ++++ web/src/features/social/use-rating.ts | 41 ++++ web/src/features/social/use-star.ts | 45 +++++ web/src/pages/dashboard/review-detail.tsx | 186 +++++++++++++++++++ web/src/pages/dashboard/reviews.tsx | 111 +++++++++++ web/src/pages/device.tsx | 156 ++++++++++++++++ web/src/shared/ui/textarea.tsx | 22 +++ 11 files changed, 778 insertions(+) create mode 100644 web/src/features/review/use-review-detail.ts create mode 100644 web/src/features/review/use-review-list.ts create mode 100644 web/src/features/social/rating-input.tsx create mode 100644 web/src/features/social/star-button.tsx create mode 100644 web/src/features/social/use-rating.ts create mode 100644 web/src/features/social/use-star.ts create mode 100644 web/src/pages/dashboard/review-detail.tsx create mode 100644 web/src/pages/dashboard/reviews.tsx create mode 100644 web/src/pages/device.tsx create mode 100644 web/src/shared/ui/textarea.tsx diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d903146c..d888d56b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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 }) diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts new file mode 100644 index 00000000..aab7638f --- /dev/null +++ b/web/src/features/review/use-review-detail.ts @@ -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 { + return fetchJson(`/api/v1/reviews/${taskId}`) +} + +async function approveReview(taskId: number, comment?: string): Promise { + await fetchJson(`/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 { + await fetchJson(`/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'] }) + }, + }) +} diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts new file mode 100644 index 00000000..a0cef6fd --- /dev/null +++ b/web/src/features/review/use-review-list.ts @@ -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 { + const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' + return fetchJson(url) +} + +export function useReviewList(status?: string) { + return useQuery({ + queryKey: ['reviews', status], + queryFn: () => getReviewList(status), + }) +} diff --git a/web/src/features/social/rating-input.tsx b/web/src/features/social/rating-input.tsx new file mode 100644 index 00000000..e4245d65 --- /dev/null +++ b/web/src/features/social/rating-input.tsx @@ -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(null) + + const currentRating = userRating?.rating || 0 + + const handleRate = (rating: number) => { + rateMutation.mutate(rating) + } + + if (isLoading) { + return null + } + + return ( +
+
+ {[1, 2, 3, 4, 5].map((rating) => { + const isFilled = rating <= (hoveredRating || currentRating) + return ( + + ) + })} +
+ {currentRating > 0 && ( + + 你的评分: {currentRating} 星 + + )} +
+ ) +} diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx new file mode 100644 index 00000000..6424fddc --- /dev/null +++ b/web/src/features/social/star-button.tsx @@ -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 ( + + ) +} diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts new file mode 100644 index 00000000..b97d33e2 --- /dev/null +++ b/web/src/features/social/use-rating.ts @@ -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 { + return fetchJson(`/api/v1/skills/${skillId}/rating`) +} + +async function rateSkill(skillId: number, rating: number): Promise { + await fetchJson(`/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'] }) + }, + }) +} diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts new file mode 100644 index 00000000..89610107 --- /dev/null +++ b/web/src/features/social/use-star.ts @@ -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 { + return fetchJson(`/api/v1/skills/${skillId}/star`) +} + +async function toggleStar(skillId: number, starred: boolean): Promise { + if (starred) { + await fetchJson(`/api/v1/skills/${skillId}/star`, { + method: 'DELETE', + headers: getCsrfHeaders(), + }) + } else { + await fetchJson(`/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'] }) + }, + }) +} diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx new file mode 100644 index 00000000..b3746dbb --- /dev/null +++ b/web/src/pages/dashboard/review-detail.tsx @@ -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
加载中...
+ } + + if (!review) { + return
审核任务不存在
+ } + + return ( +
+
+
+

审核详情

+

审核 ID: {review.id}

+
+ +
+ + +
+
+ +

{review.skillName}

+
+
+ +

{review.namespace}/{review.skillSlug}

+
+
+ +

{review.version}

+
+
+ +

+ {review.status === 'PENDING' && '待审核'} + {review.status === 'APPROVED' && '已通过'} + {review.status === 'REJECTED' && '已拒绝'} +

+
+
+ +

{review.submittedBy}

+
+
+ +

{formatDate(review.submittedAt)}

+
+ {review.reviewedBy && ( + <> +
+ +

{review.reviewedBy}

+
+
+ +

+ {review.reviewedAt ? formatDate(review.reviewedAt) : '-'} +

+
+ + )} +
+ + {review.comment && ( +
+ +

{review.comment}

+
+ )} +
+ + {review.status === 'PENDING' && ( + +

审核操作

+ +
+ +