diff --git a/app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx b/app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx new file mode 100644 index 0000000..704c932 --- /dev/null +++ b/app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx @@ -0,0 +1,190 @@ +'use client'; + +import * as React from 'react'; +import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react'; + +import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import type { TaskRuntimeStatus } from '@/lib/task-runtime'; + +export type TaskFeedbackType = 'accept' | 'revise' | 'abandon'; + +export type TaskFeedbackItem = { + acceptance_type?: unknown; + feedback_type?: unknown; + comment?: unknown; + created_at?: unknown; + run_id?: unknown; +}; + +type Props = { + sessionId: string; + runId: string | null; + taskStatus: string; + feedbackItems: TaskFeedbackItem[]; + actionBusy: string | null; + revision?: string; + onRevisionChange?: (value: string) => void; + onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise; +}; + +const RUNTIME_STATUSES = new Set(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']); + +function isRuntimeStatus(status: string): status is TaskRuntimeStatus { + return RUNTIME_STATUSES.has(status); +} + +function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null { + if (!runId) return null; + return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null; +} + +function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null { + return [...items].reverse()[0] ?? null; +} + +function feedbackKind(item: TaskFeedbackItem): string { + return String(item.acceptance_type || item.feedback_type || ''); +} + +function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') { + if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted'); + if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested'); + if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned'); + return type || pickAppText(locale, '验收', 'Acceptance'); +} + +function FeedbackButton({ + type, + icon, + label, + actionBusy, + disabled, + onClick, +}: { + type: TaskFeedbackType; + icon: React.ReactNode; + label: string; + actionBusy: string | null; + disabled: boolean; + onClick: () => void; +}) { + const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type)); + + return ( + + ); +} + +export function TaskAcceptanceCard({ + sessionId, + runId, + taskStatus, + feedbackItems, + actionBusy, + revision, + onRevisionChange, + onSubmit, +}: Props) { + const { locale } = useAppI18n(); + const [localComment, setLocalComment] = React.useState(''); + const comment = revision ?? localComment; + const setComment = onRevisionChange ?? setLocalComment; + const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned'; + const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null); + const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy; + const trimmedComment = comment.trim(); + + const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => { + if (!runId || !canSubmit) return; + void onSubmit(feedbackType, nextComment); + }; + + return ( + + +
+ {pickAppText(locale, '任务验收', 'Task acceptance')} + {isRuntimeStatus(taskStatus) ? ( + + ) : ( + + {taskStatus} + + )} +
+
+ + {recordedFeedback ? ( +
+
+ + {pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)} +
+ {recordedFeedback.comment ?

{String(recordedFeedback.comment)}

: null} + {recordedFeedback.created_at ? ( +

{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}

+ ) : null} +
+ ) : isFinalized ? ( +
+ {pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')} +
+ ) : !runId ? ( +
+ {pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')} +
+ ) : null} + +
+ } + label={pickAppText(locale, '接受', 'Accept')} + actionBusy={actionBusy} + disabled={!canSubmit} + onClick={() => submit('accept', trimmedComment || undefined)} + /> + } + label={pickAppText(locale, '需要修改', 'Needs revision')} + actionBusy={actionBusy} + disabled={!canSubmit || !trimmedComment} + onClick={() => submit('revise', trimmedComment)} + /> + } + label={pickAppText(locale, '放弃', 'Abandon')} + actionBusy={actionBusy} + disabled={!canSubmit} + onClick={() => submit('abandon', trimmedComment || undefined)} + /> +
+ +