feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { useRef, useState, type Ref } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Radio } from "@/shared/ui/radio";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
|
||||
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
|
||||
|
||||
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
|
||||
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
|
||||
}) {
|
||||
const questions = task.questions ?? [];
|
||||
if (!questions.length) return null;
|
||||
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
|
||||
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
|
||||
request={request} onAnswer={onAnswer}
|
||||
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
|
||||
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
|
||||
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
|
||||
question_id: q.question_id, selected_option_ids: [], custom_text: ""
|
||||
})));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const attempt = useRef<{ body: string; id: string } | null>(null);
|
||||
const pending = request.status === "pending" && !submitted;
|
||||
const disabled = !canAnswer || !onAnswer || busy || !pending;
|
||||
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|
||||
|| (q.allow_custom && answers[index].custom_text.trim()));
|
||||
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
|
||||
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
|
||||
const submit = async () => {
|
||||
if (disabled || !valid) return;
|
||||
const body = JSON.stringify(answers);
|
||||
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
|
||||
setSubmitted(true);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
|
||||
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
|
||||
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
|
||||
{request.questions.map((question, index) => {
|
||||
const answer = answers[index];
|
||||
const saved = request.answers.find((item) => item.question_id === question.question_id);
|
||||
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
|
||||
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
|
||||
{pending ? <>
|
||||
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
|
||||
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
|
||||
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
|
||||
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
|
||||
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
|
||||
})} />}
|
||||
<span className="min-w-0 break-words">{option.label}</span>
|
||||
</label>)}
|
||||
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
|
||||
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
|
||||
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
|
||||
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
|
||||
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
|
||||
</fieldset>;
|
||||
})}
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
{pending ? <div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
|
||||
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
|
||||
</div> : null}
|
||||
</article>;
|
||||
}
|
||||
Reference in New Issue
Block a user