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

这个提交包含在:
2026-03-10 14:20:59 +08:00
父节点 6f534e9a3c
当前提交 67237badf1
共修改 6 个文件,包含 355 行新增和 0 行删除
+79
查看文件
@@ -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<string, unknown>;
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<AiStatusResponse>("/api/ai/status");
}
export async function fetchReimbursementSuggestion(reimbursementId: string) {
return apiRequestData<AiSuggestionResponse>(`/api/ai/suggestions/reimbursements/${reimbursementId}`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchPurchaseSuggestion(purchaseRequestId: string) {
return apiRequestData<AiSuggestionResponse>(
`/api/ai/suggestions/purchase-requests/${purchaseRequestId}`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function fetchAssetSuggestion(assetId: string) {
return apiRequestData<AiSuggestionResponse>(`/api/ai/suggestions/assets/${assetId}`, {
method: "POST",
body: JSON.stringify({}),
});
}
+1
查看文件
@@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"budget.manage",
"integration.manage",
"notification.channel.manage",
"ai.assist",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
@@ -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>
);
}
+5
查看文件
@@ -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() {
</div>
) : null}
{selectedAssetId && !isCreatingAsset && currentAsset ? (
<AiSuggestionCard targetType="asset_card" targetId={currentAsset.id} />
) : null}
<div className="flex items-center gap-2">
{canManage ? (
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
@@ -32,6 +32,7 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -1266,6 +1267,8 @@ export function PurchaseRequestsPage() {
)}
</div>
) : null}
<AiSuggestionCard targetType="purchase_request" targetId={detail.id} />
</>
) : null}
</div>
@@ -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() {
</div>
) : null}
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">