Files
next-tjwater-frontend/src/features/workbench/components/investigation-questions.tsx
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

83 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}