feat(web): add review center, social features, and device auth page

This commit is contained in:
vsxd 2026-03-12 19:12:50 +08:00
parent fae775eb97
commit ddb940d5d5
11 changed files with 778 additions and 0 deletions

View file

@ -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 })

View 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'] })
},
})
}

View 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),
})
}

View 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>
)
}

View 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>
)
}

View 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'] })
},
})
}

View 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'] })
},
})
}

View 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>
)
}

View 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
View 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>
)
}

View 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 }