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

293 lines
12 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,
ElderAiDataScope,
ElderAiRecommendationPriority,
ElderAiSeverity,
} from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types";
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: "未知",
};
const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
completed: "已完成",
failed: "未完成",
};
const severityLabels: Record<ElderAiSeverity, string> = {
info: "提示",
warning: "关注",
critical: "紧急",
};
const recommendationPriorityLabels: Record<ElderAiRecommendationPriority, string> = {
low: "低",
normal: "常规",
high: "高",
urgent: "紧急",
};
const dataScopeLabels: Record<ElderAiDataScope, string> = {
elder: "基础档案",
health: "健康数据",
care: "护理服务",
family: "家属服务",
admission: "入住信息",
facility: "床位设施",
alert: "规则预警",
incident: "安全事件",
knowledge: "知识库",
};
function severityVariant(severity: ElderAiSeverity): "danger" | "secondary" | "warning" {
if (severity === "critical") {
return "danger";
}
if (severity === "warning") {
return "warning";
}
return "secondary";
}
function recommendationVariant(priority: ElderAiRecommendationPriority): "danger" | "secondary" | "warning" {
if (priority === "urgent") {
return "danger";
}
if (priority === "high") {
return "warning";
}
return "secondary";
}
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} 的授权数据范围内智能分析历史和最新结论。` : undefined}
onClose={onClose}
open={open}
title="智能照护分析"
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} · {CARE_LEVEL_LABELS[elder.careLevel]} · {ELDER_STATUS_LABELS[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={severityVariant(finding.severity)}>{severityLabels[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={recommendationVariant(recommendation.priority)}>
{recommendationPriorityLabels[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 className="gap-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle></CardTitle>
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
</div>
<Badge variant={latest.status === "failed" ? "destructive" : "secondary"}>{latest.status === "failed" ? "生成失败" : "受限"}</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 text-sm text-muted-foreground">
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
{latest.status === "failed" ? <p></p> : null}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
</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.map((scope) => dataScopeLabels[scope]).join("、") || "-"}
</p>
</div>
<div className="flex items-center gap-2">
{item.restricted ? <Badge variant="secondary"></Badge> : null}
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{statusLabels[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>
);
}