83 lines
5.6 KiB
TypeScript
83 lines
5.6 KiB
TypeScript
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>;
|
||
}
|