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