233 lines
12 KiB
TypeScript
233 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|