Files
next-tjwater-frontend/src/features/agent/answer-ui/answer-action-components.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

233 lines
12 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 { useContext, useId, useRef, useState } from "react";
import { ArrowUpRight, CheckCircle2, FileCheck2, Info, LoaderCircle, MapPin, TriangleAlert } from "lucide-react";
import { useAnswerMotion } from "./answer-motion-context";
import { motion } from "motion/react";
import { agentEase } from "../components/agent-motion";
import { AnswerDisclosure, AnswerDisclosureChevron } from "./answer-motion";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { useAnswerNodeState } from "./answer-view-state-provider";
import { answerStatePointer, type AnswerFormState } from "./answer-view-state";
type ActionState = "idle" | "pending" | "success" | "partial" | "unconfirmed" | "error";
function ActionFeedback({ state, children }: { state: ActionState; children: string }) {
const shouldAnimate = useAnswerMotion();
const animate = shouldAnimate();
const Icon = state === "pending" ? LoaderCircle : state === "success" ? CheckCircle2 : state === "error" || state === "partial" ? TriangleAlert : Info;
return <motion.p key={state} initial={animate ? { opacity: 0, y: 3 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }} role={state === "idle" ? undefined : "status"} className="mt-1 flex items-start gap-1.5 text-xs leading-5 text-muted-foreground"><Icon size={14} className={`mt-0.5 shrink-0 ${state === "pending" && animate ? "motion-safe:animate-spin" : ""}`} aria-hidden />{children}</motion.p>;
}
export function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const runningRef = useRef(false);
const [showIds, setShowIds] = useState(false);
const [state, setState] = useState<ActionState>("idle");
const running = state === "pending";
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-location" role="group" aria-label={label} data-state={state} aria-busy={running}>
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
if (!onLocate || disabled || runningRef.current) return;
runningRef.current = true;
setState("pending");
setFeedback(null);
try {
const result = await onLocate(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
if (!output || !Array.isArray(output.located_ids)) {
setState("unconfirmed");
setFeedback("定位请求已处理,未返回定位结果,请查看地图确认。");
} else {
const located = output.located_ids.length;
const missing = output.missing_ids?.length ?? 0;
setState(!located ? "error" : missing ? "partial" : "success");
setFeedback(!located ? "未找到可定位的关联要素,请检查要素编号后重试。" : missing ? `已定位 ${located} 个,另有 ${missing} 个未找到` : `已定位 ${located} 个关联要素到地图`);
}
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "地图定位失败,请重试。");
} finally {
runningRef.current = false;
}
}}
>
{running ? "正在定位…" : state === "error" ? `重试${label}` : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{target.feature_ids.length} 个关联要素 · 依据:{source}</p>
<AnswerDisclosure open={showIds} onOpenChange={setShowIds} className="mt-1 text-[11px] leading-5 text-muted-foreground"
trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}>
<p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p>
</AnswerDisclosure>
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div>
</Card>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
export function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [{ values }, patch] = useAnswerNodeState<AnswerFormState>("FollowUpForm", { title, description, fields });
const submittingRef = useRef(false);
const [state, setState] = useState<ActionState>("idle");
const submitting = state === "pending";
const [invalidFields, setInvalidFields] = useState<string[]>([]);
const setValue = (name: string, value: string) => {
patch({ values: { ...values, [name]: value } }, [`values/${answerStatePointer(name)}`]);
setState("idle");
setFeedback(null);
setInvalidFields((current) => current.filter((field) => field !== name));
};
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-form" role="group" aria-label={title} data-state={state} aria-busy={submitting}>
<CardHeader className="gap-2 border-b border-border p-3.5">
<CardTitle className="flex items-center gap-2 text-sm leading-6 tracking-normal"><FileCheck2 size={14} className="shrink-0 text-muted-foreground" aria-hidden />{title}</CardTitle>
<CardDescription className="text-xs leading-5">{description}</CardDescription>
</CardHeader>
<CardContent className="p-3.5">
<form
className="space-y-3"
aria-label={title}
aria-busy={submitting}
noValidate
onSubmit={async (event) => {
event.preventDefault();
if (!onSubmit || disabled || submittingRef.current) return;
const missing = fields.filter((field) => field.required && !values[field.name]?.trim()).map((field) => field.name);
setInvalidFields(missing);
if (missing.length) {
setState("error");
setFeedback("请填写必填资料。");
return;
}
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setState("error");
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setState("error");
setFeedback("补充资料过长,请分次发送。");
return;
}
submittingRef.current = true;
setState("pending");
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setState("success");
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
submittingRef.current = false;
}
}}
>
{fields.map((field) => (
<div key={field.name} role={field.kind === "datetime" ? "group" : undefined} aria-label={field.kind === "datetime" ? `${field.label}${field.required ? "(必填)" : ""}` : undefined} aria-invalid={invalidFields.includes(field.name) || undefined} aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}>
{field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValue(field.name, value)}
/>
) : (
<div key={field.name} className="space-y-1.5">
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
{field.label}
{field.required ? "(必填)" : ""}
</label>
{field.kind === "text" ? (
<Textarea
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
)}
</div>
)}
{invalidFields.includes(field.name) ? <p id={`${prefix}-${field.name}-error`} className="mt-1 text-xs leading-5 text-destructive">请填写{field.label}。</p> : null}
</div>
))}
<Button
type="submit"
size="sm"
variant="default"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback || submitting || !onSubmit ? <ActionFeedback state={state}>{submitting ? submitMode === "draft" ? "正在带入对话…" : "正在发送补充资料…" : feedback ?? "当前视图无法提交资料。"}</ActionFeedback> : null}
</form>
</CardContent>
</Card>
);
}