feat(P19AI1-3): 前端AI智能建议卡片挂件注入报销/采购/资产详情
这个提交包含在:
@@ -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({}),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
在新工单中引用
屏蔽一个用户