feat: polish intelligent answer interactions
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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 { SelectForAnalysis } from "./answer-ui-interactions";
|
||||
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>
|
||||
<SelectForAnalysis title={label} text={`关联图层:${target.layer}\n要素编号:${target.feature_ids.join("、")}\n依据:${source}`} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user