From 67237badf1b0367062f13630915dc233fff25383 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 10 Mar 2026 14:20:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(P19AI1-3):=20=E5=89=8D=E7=AB=AFAI=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=BB=BA=E8=AE=AE=E5=8D=A1=E7=89=87=E6=8C=82=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E6=8A=A5=E9=94=80/=E9=87=87=E8=B4=AD/?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/ai.ts | 79 ++++++ client/src/auth/permissions.ts | 1 + client/src/components/ai/AiSuggestionCard.tsx | 264 ++++++++++++++++++ client/src/pages/AssetRegistryPage.tsx | 5 + client/src/pages/PurchaseRequestsPage.tsx | 3 + client/src/pages/ReimbursementDetailPage.tsx | 3 + 6 files changed, 355 insertions(+) create mode 100644 client/src/api/ai.ts create mode 100644 client/src/components/ai/AiSuggestionCard.tsx diff --git a/client/src/api/ai.ts b/client/src/api/ai.ts new file mode 100644 index 00000000..713060d6 --- /dev/null +++ b/client/src/api/ai.ts @@ -0,0 +1,79 @@ +import { apiRequestData } from "./http"; + +export type AiStatusResponse = { + enabled: boolean; + envEnabled: boolean; + featureFlag: { + key: string; + effectiveEnabled: boolean; + effectiveReason: string; + }; + provider: { + type: string; + model: string | null; + }; + permissions: { + canAccess: boolean; + }; +}; + +export type AiRiskHint = { + level: "low" | "medium" | "high"; + code: string; + title: string; + detail: string; +}; + +export type AiNextAction = { + code: string; + label: string; + reason: string; +}; + +export type AiSuggestionResponse = { + targetType: "reimbursement" | "purchase_request" | "asset_card"; + targetId: string; + summary: string; + riskHints: AiRiskHint[]; + nextActions: AiNextAction[]; + sourceSnapshot: Record; + providerMeta: { + provider: "mock" | "http" | string; + model: string | null; + promptHash: string; + tokenUsage: { + promptTokens: number | null; + completionTokens: number | null; + totalTokens: number | null; + }; + }; + generatedAt: string; +}; + +export async function fetchAiStatus() { + return apiRequestData("/api/ai/status"); +} + +export async function fetchReimbursementSuggestion(reimbursementId: string) { + return apiRequestData(`/api/ai/suggestions/reimbursements/${reimbursementId}`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function fetchPurchaseSuggestion(purchaseRequestId: string) { + return apiRequestData( + `/api/ai/suggestions/purchase-requests/${purchaseRequestId}`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + +export async function fetchAssetSuggestion(assetId: string) { + return apiRequestData(`/api/ai/suggestions/assets/${assetId}`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index fdbb3f51..7d608e2c 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record = { "budget.manage", "integration.manage", "notification.channel.manage", + "ai.assist", "expense.approve", "expense.finance.pay", "expense.view_all", diff --git a/client/src/components/ai/AiSuggestionCard.tsx b/client/src/components/ai/AiSuggestionCard.tsx new file mode 100644 index 00000000..f8175980 --- /dev/null +++ b/client/src/components/ai/AiSuggestionCard.tsx @@ -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 = { + reimbursement: "报销单", + purchase_request: "采购单", + asset_card: "资产卡片", +}; + +const RISK_LEVEL_LABELS: Record = { + low: "低", + medium: "中", + high: "高", +}; + +const RISK_LEVEL_CLASSES: Record = { + 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 ( + + + AI 智能建议 + 只读建议,不会自动执行 + + +
+ {targetLabel}:{message} +
+

+ AI 分析仅供参考,不作为审批、付款、归档或财务判断的最终依据。 +

+
+
+ ); +} + +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 ( + + ); + } + + if (statusQuery.isError) { + if (isApiError(statusQuery.error) && [401, 403].includes(statusQuery.error.status)) { + return null; + } + return ( + + ); + } + + if (!statusQuery.data?.permissions.canAccess || !statusQuery.data.enabled) { + return null; + } + + if (suggestionQuery.isLoading) { + return ( + + ); + } + + 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 ( + + ); + } + } + + return ( + + ); + } + + const suggestion = suggestionQuery.data; + if (!suggestion) { + return null; + } + + return ( + + + AI 智能建议 + 只读建议,不会自动执行 + + +
+
摘要
+

{suggestion.summary}

+
+ +
+
风险提示
+ {suggestion.riskHints.length ? ( +
+ {suggestion.riskHints.map((risk) => ( +
+
+ + {RISK_LEVEL_LABELS[risk.level]}风险 + + {risk.title} +
+

{risk.detail}

+
+ ))} +
+ ) : ( +

暂无明显风险提示

+ )} +
+ +
+
建议动作
+ {suggestion.nextActions.length ? ( +
+ {suggestion.nextActions.map((action) => ( +
+
{action.label}
+

{action.reason}

+
+ ))} +
+ ) : ( +

暂无建议动作

+ )} +
+ +
+ 生成时间:{formatGeneratedAt(suggestion.generatedAt)} + Provider:{suggestion.providerMeta.provider} +
+ +

+ AI 分析仅供参考,不作为审批、付款、归档或财务判断的最终依据。 +

+
+
+ ); +} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 257d5b74..9d36f083 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -27,6 +27,7 @@ import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; +import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { Button } from "../components/ui/button"; @@ -996,6 +997,10 @@ export function AssetRegistryPage() { ) : null} + {selectedAssetId && !isCreatingAsset && currentAsset ? ( + + ) : null} +
{canManage ? (
) : null} + + ) : null} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 59a37559..2e334d15 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -21,6 +21,7 @@ import { hasPermission } from "../auth/permissions"; import { CardSkeleton } from "../components/common/CardSkeleton"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; +import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { Button } from "../components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; @@ -413,6 +414,8 @@ export function ReimbursementDetailPage() { ) : null} + +