feat(P19AI1-3): 前端AI智能建议卡片挂件注入报销/采购/资产详情

这个提交包含在:
2026-03-10 14:20:59 +08:00
父节点 6f534e9a3c
当前提交 67237badf1
共修改 6 个文件,包含 355 行新增和 0 行删除
@@ -0,0 +1,264 @@
import { useQuery } from "@tanstack/react-query";
import {
fetchAiStatus,
fetchAssetSuggestion,
fetchPurchaseSuggestion,
fetchReimbursementSuggestion,
type AiRiskHint,
} from "../../api/ai";
import { isApiError } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { hasPermission } from "../../auth/permissions";
import { cn } from "../../lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
type AiSuggestionTargetType = "reimbursement" | "purchase_request" | "asset_card";
type AiSuggestionCardProps = {
targetType: AiSuggestionTargetType;
targetId: string;
className?: string;
};
const TARGET_LABELS: Record<AiSuggestionTargetType, string> = {
reimbursement: "报销单",
purchase_request: "采购单",
asset_card: "资产卡片",
};
const RISK_LEVEL_LABELS: Record<AiRiskHint["level"], string> = {
low: "低",
medium: "中",
high: "高",
};
const RISK_LEVEL_CLASSES: Record<AiRiskHint["level"], string> = {
low: "bg-zinc-100 text-zinc-700",
medium: "bg-amber-100 text-amber-700",
high: "bg-red-100 text-red-700",
};
function resolveSuggestionFetcher(targetType: AiSuggestionTargetType) {
if (targetType === "reimbursement") {
return fetchReimbursementSuggestion;
}
if (targetType === "purchase_request") {
return fetchPurchaseSuggestion;
}
return fetchAssetSuggestion;
}
function formatGeneratedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function HintCard({
tone,
message,
targetLabel,
className,
}: {
tone: "neutral" | "warning" | "error";
message: string;
targetLabel: string;
className?: string;
}) {
const toneClass =
tone === "error"
? "border-red-200 bg-red-50 text-red-700"
: tone === "warning"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-zinc-200 bg-zinc-50 text-zinc-600";
return (
<Card className={cn(className)} data-testid="ai-suggestion-card">
<CardHeader>
<CardTitle>AI 智能建议</CardTitle>
<CardDescription>只读建议,不会自动执行</CardDescription>
</CardHeader>
<CardContent>
<div className={cn("rounded-lg border px-3 py-2 text-sm", toneClass)}>
{targetLabel}:{message}
</div>
<p className="mt-3 text-xs text-zinc-500">
AI 分析仅供参考,不作为审批、付款、归档或财务判断的最终依据。
</p>
</CardContent>
</Card>
);
}
export function AiSuggestionCard({ targetType, targetId, className }: AiSuggestionCardProps) {
const { permissions, ready } = useAuth();
const localCanAccess = hasPermission(permissions, "ai.assist");
const targetLabel = TARGET_LABELS[targetType];
const statusQuery = useQuery({
enabled: ready && localCanAccess && Boolean(targetId),
queryKey: ["ai", "status"],
queryFn: fetchAiStatus,
retry: false,
staleTime: 60_000,
});
const suggestionQuery = useQuery({
enabled:
ready &&
localCanAccess &&
Boolean(targetId) &&
Boolean(statusQuery.data?.enabled) &&
Boolean(statusQuery.data?.permissions.canAccess),
queryKey: ["ai", "suggestions", targetType, targetId],
queryFn: () => resolveSuggestionFetcher(targetType)(targetId),
retry: false,
});
if (!targetId || !ready || !localCanAccess) {
return null;
}
if (statusQuery.isLoading) {
return (
<HintCard
tone="neutral"
message="AI 状态检查中..."
targetLabel={targetLabel}
className={className}
/>
);
}
if (statusQuery.isError) {
if (isApiError(statusQuery.error) && [401, 403].includes(statusQuery.error.status)) {
return null;
}
return (
<HintCard
tone="error"
message="AI 建议暂不可用,请稍后重试"
targetLabel={targetLabel}
className={className}
/>
);
}
if (!statusQuery.data?.permissions.canAccess || !statusQuery.data.enabled) {
return null;
}
if (suggestionQuery.isLoading) {
return (
<HintCard
tone="neutral"
message="AI 建议生成中..."
targetLabel={targetLabel}
className={className}
/>
);
}
if (suggestionQuery.isError) {
if (isApiError(suggestionQuery.error)) {
if (suggestionQuery.error.status === 403) {
return null;
}
if (
suggestionQuery.error.status === 409 &&
suggestionQuery.error.details?.ai === "disabled"
) {
return (
<HintCard
tone="warning"
message="当前单据未纳入 AI 范围"
targetLabel={targetLabel}
className={className}
/>
);
}
}
return (
<HintCard
tone="error"
message="AI 建议暂不可用,请稍后重试"
targetLabel={targetLabel}
className={className}
/>
);
}
const suggestion = suggestionQuery.data;
if (!suggestion) {
return null;
}
return (
<Card className={cn(className)} data-testid="ai-suggestion-card">
<CardHeader>
<CardTitle>AI 智能建议</CardTitle>
<CardDescription>只读建议,不会自动执行</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">摘要</div>
<p className="text-sm leading-6 text-zinc-700">{suggestion.summary}</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">风险提示</div>
{suggestion.riskHints.length ? (
<div className="space-y-2">
{suggestion.riskHints.map((risk) => (
<div key={risk.code} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
RISK_LEVEL_CLASSES[risk.level],
)}
>
{RISK_LEVEL_LABELS[risk.level]}风险
</span>
<span className="font-medium text-zinc-900">{risk.title}</span>
</div>
<p className="mt-1 text-zinc-600">{risk.detail}</p>
</div>
))}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无明显风险提示</p>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">建议动作</div>
{suggestion.nextActions.length ? (
<div className="space-y-2">
{suggestion.nextActions.map((action) => (
<div key={action.code} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="font-medium text-zinc-900">{action.label}</div>
<p className="mt-1 text-zinc-600">{action.reason}</p>
</div>
))}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无建议动作</p>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
<span>生成时间:{formatGeneratedAt(suggestion.generatedAt)}</span>
<span>Provider:{suggestion.providerMeta.provider}</span>
</div>
<p className="text-xs text-zinc-500">
AI 分析仅供参考,不作为审批、付款、归档或财务判断的最终依据。
</p>
</CardContent>
</Card>
);
}