feat: polish intelligent answer interactions
This commit is contained in:
@@ -1,48 +1,55 @@
|
||||
import { AskAboutContent } from "./answer-ui-interactions";
|
||||
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
|
||||
import { useContext, useId, useState } from "react";
|
||||
import { LocateButton, FollowUpForm } from "./answer-action-components";
|
||||
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
|
||||
import { Renderer, defineRegistry } from "@json-render/react";
|
||||
import { useContext, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
ClipboardList,
|
||||
Database,
|
||||
FileCheck2,
|
||||
FlaskConical,
|
||||
Lightbulb,
|
||||
MapPin,
|
||||
MessageSquare
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { InteractiveChart } from "./interactive-chart";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
|
||||
import { AnswerResult } from "./answer-result";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { answerUiCatalog } from "./catalog";
|
||||
import type { AnswerUiBlock } from "./contract";
|
||||
import { AnswerContentNav } from "./answer-content-nav";
|
||||
import { getAnswerTaskLayout } from "./answer-task-layout";
|
||||
import { AnswerSelectionTray } from "./answer-selection-tray";
|
||||
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
|
||||
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
|
||||
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
|
||||
|
||||
const { registry } = defineRegistry(answerUiCatalog, {
|
||||
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
components: {
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"answer-ui-stack",
|
||||
props.columns === "two" && "answer-ui-stack-comparison",
|
||||
(props.columns === "two" || props.intent === "comparison") && "answer-ui-stack-comparison",
|
||||
props.gap === "compact" && "answer-ui-stack-compact"
|
||||
)}
|
||||
data-answer-intent={props.intent}
|
||||
data-answer-density={props.density}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Section: ({ props, children }) => (
|
||||
<section className="answer-ui-section">
|
||||
<section className="answer-ui-section" data-answer-role={props.role}>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ClipboardList size={15} aria-hidden="true" />
|
||||
{props.title}
|
||||
@@ -53,22 +60,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
),
|
||||
Text: ({ props }) => (
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-sm leading-6",
|
||||
props.tone === "warning"
|
||||
? "text-[var(--status-warning-foreground)]"
|
||||
: props.tone === "info"
|
||||
? "text-[var(--action-blue-hover)]"
|
||||
: "text-[var(--ink)]"
|
||||
)}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
Text: ({ props }) => props.tone === "neutral" ? (
|
||||
<div className="space-y-1">
|
||||
{props.title ? <h4 className="text-sm font-medium leading-6 text-foreground">{props.title}</h4> : null}
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{props.text}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Alert role="note" className="border-border bg-card px-3.5 py-3 text-card-foreground has-[>svg]:gap-x-2.5">
|
||||
{props.tone === "warning" ? <TriangleAlert className="text-[var(--status-warning-foreground)]" aria-hidden /> : <Info className="text-muted-foreground" aria-hidden />}
|
||||
<AlertTitle className="text-[13px]">{props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}</AlertTitle>
|
||||
<AlertDescription className="whitespace-pre-wrap text-muted-foreground">{props.text}</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
Result: ({ props }) => <AnswerResult {...props} />,
|
||||
Metric: ({ props }) => (
|
||||
<Card className="answer-ui-metric">
|
||||
<Card className="answer-ui-metric" role="group" aria-label={props.label}>
|
||||
<CardContent className="answer-ui-metric-content p-3">
|
||||
<p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p>
|
||||
<p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground">
|
||||
@@ -80,21 +86,35 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
{props.source ? (
|
||||
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
|
||||
) : null}
|
||||
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
<div className="answer-ui-metric-actions flex items-center">
|
||||
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
Table: ({ props }) => (
|
||||
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} />
|
||||
<section data-answer-table aria-label={props.title} className="min-w-0">
|
||||
<InteractiveDataTable title={props.title} columns={props.columns} rows={props.rows} />
|
||||
</section>
|
||||
),
|
||||
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />,
|
||||
Chart: ({ props }) => <InteractiveChart {...props} />,
|
||||
Evidence: ({ props }) => <EvidenceCard {...props} />,
|
||||
MapLocation: ({ props }) => <LocateButton {...props} />,
|
||||
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
|
||||
}
|
||||
});
|
||||
|
||||
export function AnswerUiView({
|
||||
const registry = withAnswerNodeIdentity(baseRegistry);
|
||||
|
||||
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
|
||||
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
|
||||
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
|
||||
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} />
|
||||
</AnswerViewStateProvider>;
|
||||
}
|
||||
|
||||
function AnswerUiBody({
|
||||
block,
|
||||
disabled = false,
|
||||
onLocate,
|
||||
@@ -102,25 +122,80 @@ export function AnswerUiView({
|
||||
onAsk,
|
||||
submitMode = "send"
|
||||
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
|
||||
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
|
||||
const [selectionError, setSelectionError] = useState<string | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const taskLayout = getAnswerTaskLayout(block.spec);
|
||||
const root = block.spec.elements[block.spec.root];
|
||||
const density = root.type === "Stack" ? root.props.density : undefined;
|
||||
const intent = root.type === "Stack" ? root.props.intent : undefined;
|
||||
const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
|
||||
const ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
|
||||
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
|
||||
const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
|
||||
{["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
|
||||
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
|
||||
</Button>)}
|
||||
</div> : null;
|
||||
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
|
||||
return (
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
|
||||
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
|
||||
<div className="answer-ui-root" data-answer-ui={block.block_id}>
|
||||
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
|
||||
{["all", ...kinds].map((kind) => <Button key={kind} variant="outline" size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
|
||||
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
|
||||
</Button>)}
|
||||
</div> : null}
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={block.spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
onAsk: ask, selections,
|
||||
onToggleSelection: onAsk ? (selection) => {
|
||||
setSelectionError(null);
|
||||
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
|
||||
if (alreadySelected) {
|
||||
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
|
||||
return;
|
||||
}
|
||||
const next = [...selections, selection];
|
||||
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
|
||||
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
|
||||
return;
|
||||
}
|
||||
setSelections(next);
|
||||
} : undefined }}>
|
||||
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
|
||||
<AnswerContentNav block={block} contentRef={contentRef} />
|
||||
<div ref={contentRef}>
|
||||
<>
|
||||
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
|
||||
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
|
||||
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
|
||||
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
|
||||
{renderNodes(taskLayout.analysis)}
|
||||
</section> : null}
|
||||
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
|
||||
{taskLayout.evidence.length ? <TaskSupport enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}>
|
||||
{evidenceFilters}{renderNodes(taskLayout.evidence)}
|
||||
</TaskSupport> : null}
|
||||
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}>
|
||||
{renderNodes(taskLayout.followUp)}
|
||||
</TaskSupport> : null}
|
||||
</div> : null}
|
||||
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
|
||||
</>
|
||||
</div>
|
||||
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
|
||||
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
|
||||
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
|
||||
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
|
||||
</AnswerMotionScope>
|
||||
</InteractionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section"
|
||||
contentClassName="answer-task-support-content space-y-2.5 pt-2"
|
||||
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
|
||||
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span>
|
||||
<AnswerDisclosureChevron open={open} />
|
||||
</Button>}>{children}</AnswerDisclosure>;
|
||||
}
|
||||
|
||||
const evidenceKinds = {
|
||||
observed: { label: "查询记录", icon: Database },
|
||||
human_input: { label: "用户提供", icon: MessageSquare },
|
||||
@@ -136,14 +211,15 @@ function EvidenceCard(props: {
|
||||
observed_at: string | null;
|
||||
evidence_id: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [{ open }, patch] = useAnswerNodeState<AnswerEvidenceState>("Evidence", props);
|
||||
const setOpen = (open: boolean) => patch({ open });
|
||||
const meta = evidenceKinds[props.kind];
|
||||
const Icon = meta.icon;
|
||||
const { evidenceFilter } = useContext(InteractionContext);
|
||||
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
|
||||
<CollapsibleTrigger asChild>
|
||||
<AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-ui-evidence" role="group" aria-label={props.title}
|
||||
contentClassName="px-3 pb-3 text-xs leading-6" trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
@@ -161,14 +237,8 @@ function EvidenceCard(props: {
|
||||
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0", open && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
|
||||
<AnswerDisclosureChevron open={open} />
|
||||
</Button>}>
|
||||
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
|
||||
{props.observed_at || props.evidence_id ? (
|
||||
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
|
||||
@@ -186,186 +256,10 @@ function EvidenceCard(props: {
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function LocateButton({
|
||||
label,
|
||||
source,
|
||||
...target
|
||||
}: AnswerUiMapTarget & { label: string; source: string }) {
|
||||
const { disabled, onLocate } = useContext(InteractionContext);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="answer-ui-location">
|
||||
<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 () => {
|
||||
setRunning(true);
|
||||
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;
|
||||
setFeedback(
|
||||
output?.missing_ids?.length
|
||||
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
|
||||
: "已定位到地图"
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "地图定位失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{running ? "正在定位…" : label}
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
|
||||
{feedback ? (
|
||||
<p role="status" className="mt-1 text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FormField = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "datetime" | "number";
|
||||
required: boolean;
|
||||
};
|
||||
function FollowUpForm({
|
||||
title,
|
||||
description,
|
||||
fields
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FormField[];
|
||||
}) {
|
||||
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
|
||||
const prefix = useId();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
return (
|
||||
<form
|
||||
className="answer-ui-form space-y-3"
|
||||
aria-label={title}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
|
||||
setFeedback("请填写必填资料。");
|
||||
return;
|
||||
}
|
||||
if (!onSubmit || disabled || submitting) return;
|
||||
const entries = fields
|
||||
.filter((field) => values[field.name]?.trim())
|
||||
.map(
|
||||
(field) =>
|
||||
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
|
||||
);
|
||||
if (!entries.length) {
|
||||
setFeedback("请至少填写一项资料。");
|
||||
return;
|
||||
}
|
||||
if (entries.join("\n").length > 9000) {
|
||||
setFeedback("补充资料过长,请分次发送。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await onSubmit(
|
||||
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
|
||||
);
|
||||
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="answer-ui-evidence-icon">
|
||||
<FileCheck2 size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
|
||||
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field) =>
|
||||
field.kind === "datetime" ? (
|
||||
<DateTimeField
|
||||
key={field.name}
|
||||
label={`${field.label}${field.required ? "(必填)" : ""}`}
|
||||
value={values[field.name] ?? ""}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(value) => setValues((current) => ({ ...current, [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}`}
|
||||
value={values[field.name] ?? ""}
|
||||
maxLength={500}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setValues((current) => ({ ...current, [field.name]: value }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="h-10"
|
||||
id={`${prefix}-${field.name}`}
|
||||
type="number"
|
||||
value={values[field.name] ?? ""}
|
||||
maxLength={500}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setValues((current) => ({ ...current, [field.name]: value }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
>
|
||||
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
|
||||
</Button>
|
||||
{feedback ? (
|
||||
<p role="status" className="text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AnswerDisclosure>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user