214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
'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<unknown>;
|
|
};
|
|
|
|
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
|
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
|
|
|
|
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 humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
|
const labels: Record<string, [string, string]> = {
|
|
open: ['已创建', 'Open'],
|
|
running: ['执行中', 'Running'],
|
|
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
|
needs_revision: ['需要修改', 'Needs revision'],
|
|
closed: ['已完成', 'Closed'],
|
|
abandoned: ['已放弃', 'Abandoned'],
|
|
accept: ['接受', 'Accepted'],
|
|
satisfied: ['接受', 'Accepted'],
|
|
revise: ['请求修改', 'Revision requested'],
|
|
abandon: ['放弃任务', 'Abandoned'],
|
|
};
|
|
const label = labels[status];
|
|
return label ? pickAppText(locale, label[0], label[1]) : status;
|
|
}
|
|
|
|
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 (
|
|
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
|
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
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 isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
|
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
|
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
|
const trimmedComment = comment.trim();
|
|
|
|
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
|
if (!runId || !canSubmit) return;
|
|
void onSubmit(feedbackType, nextComment);
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
|
{isRuntimeStatus(taskStatus) ? (
|
|
<TaskRuntimeStatusBadge status={taskStatus} />
|
|
) : (
|
|
<Badge variant="outline" className="text-[11px]">
|
|
{humanTaskStatus(taskStatus, locale)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{recordedFeedback ? (
|
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
|
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
|
</div>
|
|
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
|
{recordedFeedback.created_at ? (
|
|
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
|
) : null}
|
|
</div>
|
|
) : isFinalized ? (
|
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
|
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
|
</div>
|
|
) : !isReadyForAcceptance ? (
|
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
|
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
|
|
</div>
|
|
) : !runId ? (
|
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
|
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
<FeedbackButton
|
|
type="accept"
|
|
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
|
label={pickAppText(locale, '接受', 'Accept')}
|
|
actionBusy={actionBusy}
|
|
disabled={!canSubmit}
|
|
onClick={() => submit('accept', trimmedComment || undefined)}
|
|
/>
|
|
<FeedbackButton
|
|
type="revise"
|
|
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
|
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
|
actionBusy={actionBusy}
|
|
disabled={!canSubmit || !trimmedComment}
|
|
onClick={() => submit('revise', trimmedComment)}
|
|
/>
|
|
<FeedbackButton
|
|
type="abandon"
|
|
icon={<XCircle className="mr-2 h-4 w-4" />}
|
|
label={pickAppText(locale, '放弃', 'Abandon')}
|
|
actionBusy={actionBusy}
|
|
disabled={!canSubmit}
|
|
onClick={() => submit('abandon', trimmedComment || undefined)}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
value={comment}
|
|
onChange={(event) => setComment(event.target.value)}
|
|
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
|
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
|
/>
|
|
<div className="text-xs text-muted-foreground">
|
|
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
|
<span className="font-mono">{runId || '-'}</span>
|
|
<span className="mx-1">·</span>
|
|
{pickAppText(locale, '会话:', 'Session: ')}
|
|
<span className="font-mono">{sessionId}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|