返回 AiToEarn
RatingModal.tsx
根目录 / project / aitoearn-web / src / components / Chat / Rating / RatingModal.tsx
1 /**
2 * RatingModal - 任务评分模态框(复用)
3 * 功能:查看/提交对任务的评分(1-5)和评论。评分 < 3 时评论为必填。
4 */
5
6 'use client'
7
8 import { Loader2 } from 'lucide-react'
9 import React, { useEffect, useState } from 'react'
10 import { agentApi } from '@/api/ai/ai.api'
11 import { useTransClient } from '@/app/i18n/client'
12 import { Modal } from '@/components/ui/modal'
13 import { StarRating } from '@/components/ui/star-rating'
14 import { Textarea } from '@/components/ui/textarea'
15 import { toast } from '@/utils/ui/toast'
16
17 interface RatingModalProps {
18 taskId: string
19 open: boolean
20 onClose: () => void
21 onSaved?: (data: { rating?: number | null, comment?: string | null }) => void
22 /** 默认选中的评分(用于从外部传入初始值,如用户在列表中点击的星级) */
23 defaultRating?: number | null
24 }
25
26 export const RatingModal: React.FC<RatingModalProps> = ({
27 taskId,
28 open,
29 onClose,
30 onSaved,
31 defaultRating,
32 }) => {
33 const { t } = useTransClient('chat')
34 const [rating, setRating] = useState<number | null>(null)
35 const [comment, setComment] = useState<string>('')
36 const [loading, setLoading] = useState(false)
37 const [initialLoaded, setInitialLoaded] = useState(false)
38 const [fetchingRating, setFetchingRating] = useState(false)
39
40 useEffect(() => {
41 if (!open)
42 return
43 let mounted = true
44 setInitialLoaded(false)
45 setFetchingRating(true)
46 ;(async () => {
47 try {
48 // Some deployments don't expose GET /tasks/:id/rating.
49 // The existing task detail GET returns rating & ratingComment, so prefer that.
50 const res = await agentApi.getTaskDetail(taskId)
51 if (!mounted)
52 return
53 // Support both wrapped response { code, data } and direct TaskDetail
54 const payload = res && (res as any).data ? (res as any).data : res
55 const apiRating = (payload && (payload.rating ?? null)) ?? null
56 // 如果 API 返回的 rating 为 null,且有传入 defaultRating,则使用 defaultRating
57 setRating(apiRating ?? defaultRating ?? null)
58 setComment((payload && (payload.ratingComment ?? '')) ?? '')
59 }
60 catch (err) {
61 // ignore 404 / not rated
62 console.error('Get rating failed', err)
63 // 加载失败时,如果有 defaultRating,使用它
64 if (mounted && defaultRating) {
65 setRating(defaultRating)
66 }
67 }
68 finally {
69 if (mounted) {
70 setInitialLoaded(true)
71 setFetchingRating(false)
72 }
73 }
74 })()
75 return () => {
76 mounted = false
77 }
78 }, [open, taskId, defaultRating])
79
80 const canSubmit = () => {
81 if (!rating)
82 return false
83 if (rating < 3 && comment.trim().length === 0)
84 return false
85 return true
86 }
87
88 const handleSubmit = async () => {
89 if (!canSubmit()) {
90 toast.error(t('rating.requireComment') || 'Please provide a comment for low ratings')
91 return
92 }
93 try {
94 setLoading(true)
95 await agentApi.submitTaskRating(taskId, { rating: rating as number, comment: comment.trim() })
96 // Delegate success toast to caller to avoid duplicate notifications
97 onSaved?.({ rating: rating as number, comment: comment.trim() })
98 onClose()
99 }
100 catch (err) {
101 console.error(err)
102 toast.error(t('rating.saveFailed') || 'Save failed')
103 }
104 finally {
105 setLoading(false)
106 }
107 }
108
109 return (
110 <Modal
111 open={open}
112 title={t('rating.title')}
113 onCancel={onClose}
114 onOk={handleSubmit}
115 okText={t('rating.submit')}
116 cancelText={t('rating.cancel')}
117 confirmLoading={loading}
118 width={520}
119 >
120 <div className="space-y-4 w-full">
121 {fetchingRating ? (
122 <div className="flex items-center justify-center py-8">
123 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
124 <span className="ml-2 text-sm text-muted-foreground">
125 {t('rating.loading') || '加载中...'}
126 </span>
127 </div>
128 ) : (
129 <>
130 <div>
131 <StarRating value={rating} onChange={setRating} size="md" className="gap-2" />
132 <div className="text-xs text-muted-foreground mt-1">
133 {rating ? `${rating} / 5` : t('rating.noRating') || '未评分'}
134 </div>
135 </div>
136
137 <div>
138 <label className="block text-sm font-medium text-foreground mb-1">
139 {t('rating.commentLabel') || '评价(可选)'}
140 </label>
141 <Textarea
142 value={comment}
143 onChange={e => setComment(e.target.value)}
144 className="min-h-[100px] resize-vertical"
145 placeholder={t('rating.commentPlaceholder') || '填写评价...'}
146 />
147 {rating !== null && rating < 3 && (
148 <div className="text-xs text-destructive mt-1">
149 {t('rating.commentRequiredIfLow') || '低于 3 分需要填写理由'}
150 </div>
151 )}
152 </div>
153 </>
154 )}
155 </div>
156 </Modal>
157 )
158 }
159
160 export default RatingModal
161
161 lines Plain Text