234 lines
12 KiB
TypeScript
234 lines
12 KiB
TypeScript
import { LocateButton, FollowUpForm } from "./answer-action-components";
|
|
import { Renderer, defineRegistry } from "@json-render/react";
|
|
import { useContext, useRef, useState, type ReactNode } from "react";
|
|
import {
|
|
Info,
|
|
TriangleAlert,
|
|
ClipboardList,
|
|
Database,
|
|
FileCheck2,
|
|
FlaskConical,
|
|
Lightbulb,
|
|
MessageSquare
|
|
} from "lucide-react";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Badge } from "@/shared/ui/badge";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
|
|
import { Card, CardContent } 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 { AnswerResult } from "./answer-result";
|
|
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } 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 { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
|
|
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
|
|
import { withAnswerDisplayTimes } from "./answer-display-time";
|
|
|
|
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
|
|
actions: {},
|
|
components: {
|
|
Stack: ({ props, children }) => (
|
|
<div
|
|
className={cn(
|
|
"answer-ui-stack",
|
|
(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" 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}
|
|
</h3>
|
|
{props.description ? (
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{props.description}</p>
|
|
) : null}
|
|
<div className="mt-3 space-y-3">{children}</div>
|
|
</section>
|
|
),
|
|
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" 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">
|
|
{props.value}
|
|
{props.unit ? (
|
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">{props.unit}</span>
|
|
) : null}
|
|
</p>
|
|
{props.source ? (
|
|
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
),
|
|
Table: ({ props }) => (
|
|
<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 {...props} />,
|
|
Evidence: ({ props }) => <EvidenceCard {...props} />,
|
|
MapLocation: ({ props }) => <LocateButton {...props} />,
|
|
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
|
|
}
|
|
});
|
|
|
|
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(withAnswerDisplayTimes(props.block.spec)) }} />
|
|
</AnswerViewStateProvider>;
|
|
}
|
|
|
|
function AnswerUiBody({
|
|
block,
|
|
disabled = false,
|
|
onLocate,
|
|
onSubmit,
|
|
chartWindowKey,
|
|
submitMode = "send"
|
|
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
|
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
|
|
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 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 isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
|
|
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, chartWindowKey }}>
|
|
<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="分析内容">
|
|
{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 grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
|
|
{evidenceFilters}{renderNodes(taskLayout.evidence)}
|
|
</TaskSupport> : null}
|
|
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
|
|
{renderNodes(taskLayout.followUp)}
|
|
</TaskSupport> : null}
|
|
</div> : null}
|
|
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
|
|
</>
|
|
</div>
|
|
</AnswerMotionScope>
|
|
</InteractionContext.Provider>
|
|
);
|
|
}
|
|
|
|
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
|
|
const [open, setOpen] = useState(true);
|
|
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
|
|
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
|
|
trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
|
|
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
|
|
<AnswerDisclosureChevron open={open} />
|
|
</Button>}>{children}</AnswerDisclosure>;
|
|
}
|
|
|
|
const evidenceKinds = {
|
|
observed: { label: "查询记录", icon: Database },
|
|
human_input: { label: "用户提供", icon: MessageSquare },
|
|
inference: { label: "分析推断", icon: Lightbulb },
|
|
demo: { label: "演练数据", icon: FlaskConical }
|
|
};
|
|
|
|
function EvidenceCard(props: {
|
|
title: string;
|
|
summary: string;
|
|
source: string;
|
|
kind: keyof typeof evidenceKinds;
|
|
observed_at: string | null;
|
|
evidence_id: string | null;
|
|
}) {
|
|
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 (
|
|
<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"
|
|
className="h-auto min-h-12 w-full justify-start gap-2.5 whitespace-normal rounded-xl px-3 py-3 text-left shadow-none"
|
|
>
|
|
<span className="answer-ui-evidence-icon">
|
|
<Icon size={16} aria-hidden="true" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-xs font-medium">{props.title}</span>
|
|
<span className="mt-0.5 block break-words text-[11px] font-normal text-muted-foreground">
|
|
{props.source}
|
|
</span>
|
|
</span>
|
|
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
|
|
{meta.label}
|
|
</Badge>
|
|
<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">
|
|
{props.observed_at ? (
|
|
<div>
|
|
<dt className="inline">记录时间:</dt>
|
|
<dd className="inline tabular-nums">{props.observed_at}</dd>
|
|
</div>
|
|
) : null}
|
|
{props.evidence_id ? (
|
|
<div>
|
|
<dt className="inline">资料编号:</dt>
|
|
<dd className="inline break-all font-mono">{props.evidence_id}</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
) : null}
|
|
</AnswerDisclosure>
|
|
);
|
|
}
|