feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -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>;
}