Files
teatea-pension/modules/ai/components/ElderAiAnalysisDialog.tsx

227 lines
9.7 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.
"use client";
import { useEffect, useState } from "react";
import { Brain, RefreshCw, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import type { Elder } from "@/modules/elders/types";
type ElderAiAnalysisDialogProps = {
elder: Elder | null;
open: boolean;
onClose: () => void;
};
const riskLabels: Record<string, string> = {
low: "低",
medium: "中",
high: "高",
critical: "紧急",
unknown: "未知",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
async function loadHistory(target: Elder): Promise<void> {
setIsLoading(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
setIsLoading(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setHistory(result.history);
}
useEffect(() => {
if (open && elder) {
void loadHistory(elder);
}
}, [elder, open]);
async function generate(): Promise<void> {
if (!elder) {
return;
}
setIsGenerating(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
setIsGenerating(false);
if (!result.success) {
setMessage(result.reason);
await loadHistory(elder);
return;
}
setHistory((current) => [result.analysis, ...current]);
setMessage(result.reason);
}
const latest = history[0];
const latestResult = latest && !latest.restricted ? latest.result : undefined;
return (
<Dialog
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
onClose={onClose}
open={open}
title="AI 分析"
width="wide"
>
{elder ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-lg font-semibold">{elder.name}</p>
<p className="text-sm text-muted-foreground">
{elder.age} · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
<RefreshCw className="size-4" aria-hidden="true" />
</Button>
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
<Sparkles className="size-4" aria-hidden="true" />
{isGenerating ? "生成中" : "生成分析"}
</Button>
</div>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{latestResult ? (
<Card>
<CardHeader className="gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
</div>
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-4">
<p className="text-sm leading-6">{latestResult.summary}</p>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.keyFindings.map((finding, index) => (
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{finding.category}</span>
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
</div>
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
</div>
))}
</div>
</section>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.recommendations.map((recommendation, index) => (
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{recommendation.title}</span>
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
</div>
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
<p className="mt-1">{recommendation.suggestedNextStep}</p>
</div>
))}
</div>
</section>
{latestResult.dataGaps.length > 0 ? (
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<ul className="grid gap-1 text-sm text-muted-foreground">
{latestResult.dataGaps.map((gap) => (
<li key={gap}>{gap}</li>
))}
</ul>
</section>
) : null}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.citations.map((citation) => (
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
<p className="font-medium">
[{citation.id}] {citation.title}
</p>
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
</div>
))}
</div>
</section>
</CardContent>
</Card>
) : latest ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
</CardContent>
</Card>
)}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{history.map((item) => (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
<div>
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
<p className="text-xs text-muted-foreground">{item.dataScopes.join(", ") || "-"}</p>
</div>
<div className="flex items-center gap-2">
{item.restricted ? <Badge variant="secondary"></Badge> : null}
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
</div>
</div>
))}
</div>
</section>
</div>
) : (
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
<Brain className="mr-2 size-4" aria-hidden="true" />
</div>
)}
</Dialog>
);
}