From 7cd2a183640925397851a51b615efae42f85d8c9 Mon Sep 17 00:00:00 2001 From: ntnt Date: Thu, 12 Mar 2026 20:04:27 +0800 Subject: [PATCH] update: mock mode --- frontend/src/layouts/MainLayout.tsx | 83 ++- frontend/src/mock/data.ts | 18 +- frontend/src/pages/analysis/Analysis.tsx | 160 +++-- frontend/src/pages/cases/CaseList.tsx | 9 +- frontend/src/pages/reports/Reports.tsx | 175 ++++- frontend/src/pages/review/Review.tsx | 640 +++++++++++++----- .../src/pages/screenshots/Screenshots.tsx | 554 ++++++++++++--- .../src/pages/transactions/Transactions.tsx | 4 +- frontend/src/pages/workspace/Workspace.tsx | 170 ++++- frontend/src/routes.tsx | 1 + frontend/src/services/api.ts | 74 +- frontend/src/types/index.ts | 18 +- 12 files changed, 1495 insertions(+), 411 deletions(-) diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 9adbe60..bf12ffb 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Typography, theme, Space } from 'antd'; +import { App, Layout, Menu, Typography, theme, Space, Tag } from 'antd'; import { FolderOpenOutlined, DashboardOutlined, @@ -11,29 +11,71 @@ import { FileTextOutlined, SafetyCertificateOutlined, } from '@ant-design/icons'; +import { getDataSourceMode } from '../services/api'; const { Header, Sider, Content } = Layout; const menuItems = [ - { key: '/cases', icon: , label: '案件管理' }, - { key: '/cases/1/workspace', icon: , label: '工作台' }, - { key: '/cases/1/screenshots', icon: , label: '截图管理' }, - { key: '/cases/1/transactions', icon: , label: '交易归并' }, - { key: '/cases/1/analysis', icon: , label: '资金分析' }, - { key: '/cases/1/review', icon: , label: '认定复核' }, - { key: '/cases/1/reports', icon: , label: '报告导出' }, + { key: 'cases', icon: , label: '案件管理' }, + { key: 'workspace', icon: , label: '工作台' }, + { key: 'screenshots', icon: , label: '截图管理' }, + { key: 'transactions', icon: , label: '交易归并' }, + { key: 'analysis', icon: , label: '资金分析' }, + { key: 'review', icon: , label: '认定复核' }, + { key: 'reports', icon: , label: '报告导出' }, ]; const MainLayout: React.FC = () => { const [collapsed, setCollapsed] = useState(false); + const [dataSourceMode, setDataSourceMode] = useState<'mock' | 'api'>('mock'); const navigate = useNavigate(); const location = useLocation(); const { token } = theme.useToken(); + const { message } = App.useApp(); - const selectedKey = menuItems - .map((m) => m.key) - .filter((k) => location.pathname.startsWith(k)) - .sort((a, b) => b.length - a.length)[0] || '/cases'; + useEffect(() => { + let mounted = true; + + const refreshStatus = async () => { + const mode = await getDataSourceMode(); + if (mounted) setDataSourceMode(mode); + }; + + refreshStatus(); + const timer = setInterval(refreshStatus, 10_000); + + return () => { + mounted = false; + clearInterval(timer); + }; + }, []); + + const caseMatch = location.pathname.match(/^\/cases\/([^/]+)/); + const currentCaseId = caseMatch?.[1] ?? null; + + const selectedKey = (() => { + if (location.pathname === '/cases' || location.pathname.startsWith('/cases?')) return 'cases'; + if (location.pathname.includes('/workspace')) return 'workspace'; + if (location.pathname.includes('/screenshots')) return 'screenshots'; + if (location.pathname.includes('/transactions')) return 'transactions'; + if (location.pathname.includes('/analysis')) return 'analysis'; + if (location.pathname.includes('/review')) return 'review'; + if (location.pathname.includes('/reports')) return 'reports'; + return 'cases'; + })(); + + const handleMenuClick = (key: string) => { + if (key === 'cases') { + navigate('/cases'); + return; + } + if (currentCaseId) { + navigate(`/cases/${currentCaseId}/${key}`); + return; + } + message.warning('请先在案件管理中创建或选择一个案件'); + navigate('/cases'); + }; return ( @@ -79,7 +121,7 @@ const MainLayout: React.FC = () => { mode="inline" selectedKeys={[selectedKey]} items={menuItems} - onClick={({ key }) => navigate(key)} + onClick={({ key }) => handleMenuClick(String(key))} style={{ border: 'none' }} /> @@ -100,9 +142,16 @@ const MainLayout: React.FC = () => { 受害人被骗金额归集智能体 - - 演示环境 · v0.1.0 - + + {dataSourceMode === 'mock' && ( + + 当前数据源:Mock 模式 + + )} + + 演示环境 · v0.1.0 + + = { self: '#1677ff', @@ -20,6 +20,8 @@ const nodeColorMap: Record = { const Analysis: React.FC = () => { const { id = '1' } = useParams(); + const queryClient = useQueryClient(); + const { message } = App.useApp(); const { data: txData } = useQuery({ queryKey: ['transactions', id], @@ -30,12 +32,42 @@ const Analysis: React.FC = () => { queryFn: () => fetchFlows(id), }); - const mockTransactions = txData?.items ?? []; - const mockFlowNodes = flowData?.nodes ?? []; - const mockFlowEdges = flowData?.edges ?? []; + const analysisMutation = useMutation({ + mutationFn: () => triggerAnalysis(id), + onMutate: () => { + message.open({ + key: 'analysis-page-analysis', + type: 'loading', + content: '正在提交案件分析任务...', + duration: 0, + }); + }, + onSuccess: (res) => { + message.open({ + key: 'analysis-page-analysis', + type: 'success', + content: res.message || '分析任务已提交', + }); + queryClient.invalidateQueries({ queryKey: ['assessments', id] }); + queryClient.invalidateQueries({ queryKey: ['suggestions', id] }); + queryClient.invalidateQueries({ queryKey: ['transactions', id] }); + queryClient.invalidateQueries({ queryKey: ['flows', id] }); + queryClient.invalidateQueries({ queryKey: ['case', id] }); + }, + onError: () => + message.open({ + key: 'analysis-page-analysis', + type: 'error', + content: '案件分析提交失败', + }), + }); + + const transactions: TransactionRecord[] = useMemo(() => txData?.items ?? [], [txData?.items]); + const flowNodes: FundFlowNode[] = useMemo(() => flowData?.nodes ?? [], [flowData?.nodes]); + const flowEdges: FundFlowEdge[] = useMemo(() => flowData?.edges ?? [], [flowData?.edges]); const flowChartOption = useMemo(() => { - const nodes = mockFlowNodes.map((n: any) => ({ + const nodes = flowNodes.map((n) => ({ name: n.label, symbolSize: n.type === 'suspect' ? 60 : 50, itemStyle: { color: nodeColorMap[n.type] }, @@ -43,9 +75,9 @@ const Analysis: React.FC = () => { category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2, })); - const edges = mockFlowEdges.map((e: any) => { - const src = mockFlowNodes.find((n: any) => n.id === e.source); - const tgt = mockFlowNodes.find((n: any) => n.id === e.target); + const edges = flowEdges.map((e) => { + const src = flowNodes.find((n) => n.id === e.source); + const tgt = flowNodes.find((n) => n.id === e.target); return { source: src?.label || '', target: tgt?.label || '', @@ -91,17 +123,17 @@ const Analysis: React.FC = () => { }, ], }; - }, [mockFlowNodes, mockFlowEdges]); + }, [flowNodes, flowEdges]); const timelineChartOption = useMemo(() => { - const sorted = [...mockTransactions] + const sorted = [...transactions] .filter((t) => !t.isDuplicate) .sort((a, b) => a.tradeTime.localeCompare(b.tradeTime)); return { tooltip: { trigger: 'axis', - formatter: (params: any) => { + formatter: (params: Array<{ axisValue: string; value: number }>) => { const p = params[0]; return `${p.axisValue}
金额: ¥${Math.abs(p.value).toLocaleString()}`; }, @@ -132,9 +164,9 @@ const Analysis: React.FC = () => { }, ], }; - }, [mockTransactions]); + }, [transactions]); - const validTx = mockTransactions.filter((t) => !t.isDuplicate); + const validTx = transactions.filter((t) => !t.isDuplicate); const totalFraud = validTx .filter((t) => t.direction === 'out' && !t.isTransit) .reduce((s, t) => s + t.amount, 0); @@ -145,6 +177,34 @@ const Analysis: React.FC = () => { return (
+ + + + 分析入口 + + + + + + + + {transactions.length === 0 && ( + + )} + @@ -171,7 +231,7 @@ const Analysis: React.FC = () => { @@ -217,6 +277,38 @@ const Analysis: React.FC = () => { 红色柱表示支出,绿色柱表示收入。中转和重复记录已排除。 + + + + {[ + { name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' }, + { name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' }, + { name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' }, + ].map((item, idx) => ( + + + + {item.name} +
+ + {item.count} 笔交易 + + + + + + ¥{item.amount.toLocaleString()} + + + {item.risk === 'high' ? '高风险' : '中风险'} + + + +
+
+ ))} +
+
@@ -286,38 +378,6 @@ const Analysis: React.FC = () => { }))} /> - - - - {[ - { name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' }, - { name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' }, - { name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' }, - ].map((item, idx) => ( - - - - {item.name} -
- - {item.count} 笔交易 - - - - - - ¥{item.amount.toLocaleString()} - - - {item.risk === 'high' ? '高风险' : '中风险'} - - - -
-
- ))} -
-
diff --git a/frontend/src/pages/cases/CaseList.tsx b/frontend/src/pages/cases/CaseList.tsx index 8faf8e3..97ae95d 100644 --- a/frontend/src/pages/cases/CaseList.tsx +++ b/frontend/src/pages/cases/CaseList.tsx @@ -118,8 +118,15 @@ const CaseList: React.FC = () => { render: (_, record) => ( @@ -188,7 +263,18 @@ const Reports: React.FC = () => { Excel 汇总表
- 选择 + { + setSelectedFormats((prev) => + e.target.checked + ? Array.from(new Set([...prev, 'excel'])) + : prev.filter((x) => x !== 'excel'), + ); + }} + > + 选择 +
{ PDF 报告
- 选择 + { + setSelectedFormats((prev) => + e.target.checked + ? Array.from(new Set([...prev, 'pdf'])) + : prev.filter((x) => x !== 'pdf'), + ); + }} + > + 选择 +
{ Word 文书
- 选择 + { + setSelectedFormats((prev) => + e.target.checked + ? Array.from(new Set([...prev, 'word'])) + : prev.filter((x) => x !== 'word'), + ); + }} + > + 选择 +
@@ -223,13 +331,15 @@ const Reports: React.FC = () => { 报告内容:
- 被骗金额汇总表 - 交易明细清单(含证据索引) - 资金流转路径图 - 交易时间轴 - 认定理由与排除说明 - 笔录辅助问询建议 - 原始截图附件 + {contentOptions.map((opt) => ( + toggleContent(opt.key, e.target.checked)} + > + {opt.label} + + ))}
@@ -239,7 +349,13 @@ const Reports: React.FC = () => { size="large" icon={} loading={genMutation.isPending} - onClick={() => genMutation.mutate('excel')} + onClick={() => { + if (selectedFormats.length === 0) { + message.warning('请至少选择一种导出格式'); + return; + } + genMutation.mutate(selectedFormats); + }} block > {genMutation.isPending ? '正在生成报告...' : '生成报告'} @@ -253,7 +369,9 @@ const Reports: React.FC = () => { , @@ -261,10 +379,21 @@ const Reports: React.FC = () => { key="pdf" type="primary" icon={} - onClick={() => message.info('演示模式:下载 PDF')} + href={latestReportByType.pdf ? getReportDownloadUrl(latestReportByType.pdf.id) : undefined} + target="_blank" + disabled={!latestReportByType.pdf} > 下载 PDF , + , + submitReviewAction(key as ReviewAction), + }} + trigger={['click']} + > + + + + ); + } + + const ds = doneStyle[status] || doneStyle.confirmed; return ( - - {cfg.label} - + + + submitReviewAction(key as ReviewAction), + }} + trigger={['click']} + > + + + ); }, }, { - title: '操作', - width: 100, - render: (_, r) => - r.reviewStatus === 'pending' ? ( - - ) : ( - - ), + title: '', + width: 80, + render: (_, r) => ( + + ), }, ]; @@ -274,25 +455,46 @@ const Review: React.FC = () => { } extra={ - setFilterLevel(v as string)} - options={[ - { label: '全部', value: 'all' }, - { label: '高置信', value: 'high' }, - { label: '中置信', value: 'medium' }, - { label: '低置信', value: 'low' }, - ]} - /> + allAssessments.length > 0 ? ( + setFilterLevel(v as string)} + options={[ + { label: '全部', value: 'all' }, + { label: '高置信', value: 'high' }, + { label: '中置信', value: 'medium' }, + { label: '低置信', value: 'low' }, + ]} + /> + ) : null } > - + {allAssessments.length === 0 ? ( + analysisMutation.mutate()} + > + {analysisMutation.isPending ? '分析中...' : '执行案件分析'} + + } + /> + ) : ( +
+ )} @@ -309,36 +511,186 @@ const Review: React.FC = () => { 基于当前识别和分析结果,建议在笔录中向受害人追问以下问题: -
    - {suggestions.map((s, idx) => ( -
  1. - {s} -
  2. - ))} -
+ {suggestionsLoading && suggestions.length === 0 ? ( + + ) : hasNoAnalysisResult ? ( + analysisMutation.mutate()} + > + {analysisMutation.isPending ? '分析中...' : '执行案件分析'} + + } + /> + ) : suggestions.length === 0 ? ( + analysisMutation.mutate()} + > + {analysisMutation.isPending ? '分析中...' : '执行案件分析'} + + } + /> + ) : ( +
    + {suggestions.map((s, idx) => ( +
  1. + {s} +
  2. + ))} +
+ )} - setReviewModal(null)} - footer={ - reviewModal?.reviewStatus === 'pending' - ? [ - , + onClose={() => setReviewModal(null)} + width={720} + > + {reviewModal && ( + <> + +
+ + 金额来源截图 +
+ {evidenceImageDetail?.url ? ( + 来源截图 + ) : ( + 暂无来源截图 + )} +
+
+ + + + + {reviewModal.transaction.tradeTime} + + + + ¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + + + {reviewModal.transaction.counterpartyName} + + + {reviewModal.transaction.sourceApp} + + + + {confidenceConfig[reviewModal.confidenceLevel].label} + + + + {aiSuggestionLabel[getAiSuggestedAction(reviewModal)]} + + + + + 认定理由: +
+ {reviewModal.reason} +
+ + {reviewModal.excludeReason && ( + + 排除条件: +
+ {reviewModal.excludeReason} +
+ )} + + {reviewModal.reviewStatus === 'pending' && ( + <> + + + 复核决定: + + 已默认选中 AI 建议:{aiSuggestionLabel[getAiSuggestedAction(reviewModal)]} + + - 备注说明: - setReviewNote(e.target.value)} - placeholder="请输入复核意见或备注..." - /> - - - )} - - {reviewModal.reviewStatus !== 'pending' && reviewModal.reviewedBy && ( - - - {reviewModal.reviewedBy} - - - {reviewModal.reviewedAt} - - - )} + + )} + )} - + ); }; diff --git a/frontend/src/pages/screenshots/Screenshots.tsx b/frontend/src/pages/screenshots/Screenshots.tsx index c0178e5..0dfb273 100644 --- a/frontend/src/pages/screenshots/Screenshots.tsx +++ b/frontend/src/pages/screenshots/Screenshots.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + App, Card, Row, Col, @@ -12,26 +13,27 @@ import { Badge, Descriptions, Empty, - List, Drawer, Button, - Form, - Input, - InputNumber, - DatePicker, Divider, Segmented, + Checkbox, + Alert, + Collapse, + Table, + Input, + InputNumber, } from 'antd'; import { FileImageOutlined, CheckCircleOutlined, LoadingOutlined, CloseCircleOutlined, - EditOutlined, ZoomInOutlined, + PlayCircleOutlined, } from '@ant-design/icons'; import type { EvidenceImage, SourceApp, PageType } from '../../types'; -import { fetchImages } from '../../services/api'; +import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api'; const appLabel: Record = { wechat: { label: '微信', color: 'green' }, @@ -47,7 +49,17 @@ const pageTypeLabel: Record = { transfer_receipt: '转账凭证', sms_notice: '短信通知', balance: '余额页', - unknown: '未识别', + unknown: '页面类型未识别', +}; + +const ocrStatusMeta: Record< + EvidenceImage['ocrStatus'], + { badgeStatus: 'success' | 'processing' | 'error' | 'default'; text: string } +> = { + done: { badgeStatus: 'success', text: 'OCR已完成' }, + processing: { badgeStatus: 'processing', text: 'OCR识别中' }, + pending: { badgeStatus: 'default', text: '待识别' }, + failed: { badgeStatus: 'error', text: '识别失败' }, }; const ocrStatusIcon: Record = { @@ -57,15 +69,190 @@ const ocrStatusIcon: Record = { pending: , }; +type EditableTx = { + id: string; + blockId: string; + data: Record; + jsonText: string; + jsonError?: string; +}; + +const preferredFieldOrder = [ + 'trade_time', + 'amount', + 'direction', + 'counterparty_name', + 'counterparty_account', + 'self_account_tail_no', + 'order_no', + 'remark', + 'confidence', +] as const; + +const fieldLabelMap: Record = { + trade_time: '交易时间', + amount: '金额', + direction: '方向', + counterparty_name: '对方名称', + counterparty_account: '对方账号', + self_account_tail_no: '本方尾号', + order_no: '订单号', + remark: '备注', + confidence: '置信度', + raw_content: '原始文本', +}; + +const tryParseJson = (text: string): unknown | null => { + try { + return JSON.parse(text); + } catch { + const match = text.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); + if (!match) return null; + try { + return JSON.parse(match[1]); + } catch { + return null; + } + } +}; + +const normalizeToObject = (value: unknown): Record | null => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +}; + +const buildEditableTransactions = ( + blocks: Array<{ id: string; content: string }>, +): EditableTx[] => { + const items: EditableTx[] = []; + blocks.forEach((block, blockIdx) => { + const raw = (block.content || '').trim(); + const parsed = tryParseJson(raw); + + if (Array.isArray(parsed)) { + parsed.forEach((entry, i) => { + const obj = normalizeToObject(entry); + if (!obj) return; + items.push({ + id: `${block.id}-${i}`, + blockId: block.id, + data: obj, + jsonText: JSON.stringify(obj, null, 2), + }); + }); + return; + } + + const obj = normalizeToObject(parsed); + if (obj) { + items.push({ + id: `${block.id}-0`, + blockId: block.id, + data: obj, + jsonText: JSON.stringify(obj, null, 2), + }); + return; + } + + items.push({ + id: `${block.id}-${blockIdx}`, + blockId: block.id, + data: { raw_content: raw }, + jsonText: JSON.stringify({ raw_content: raw }, null, 2), + }); + }); + return items; +}; + +const formatMoney = (v: unknown): string => { + const n = Number(v); + if (!Number.isFinite(n)) return '-'; + return n.toFixed(2); +}; + const Screenshots: React.FC = () => { const { id = '1' } = useParams(); + const queryClient = useQueryClient(); + const { message } = App.useApp(); const [filterApp, setFilterApp] = useState('all'); const [selectedImage, setSelectedImage] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [rerunTracking, setRerunTracking] = useState>({}); + const [editableTxs, setEditableTxs] = useState([]); const { data: allImages = [] } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id), + refetchInterval: Object.keys(rerunTracking).length > 0 ? 2000 : false, + }); + const triggerOcrMutation = useMutation({ + mutationFn: (targetIds: string[]) => + startCaseOcr( + id, + targetIds.length > 0, + targetIds.length > 0 ? targetIds : undefined, + ), + onMutate: (targetIds) => { + message.open({ + key: 'screenshots-ocr', + type: 'loading', + content: targetIds.length > 0 ? `正在提交选中图片 OCR(${targetIds.length})...` : '正在提交 OCR 任务...', + duration: 0, + }); + if (targetIds.length > 0) { + setRerunTracking((prev) => { + const next = { ...prev }; + targetIds.forEach((imageId) => { + next[imageId] = { sawProcessing: false }; + }); + return next; + }); + } + return { targetIds }; + }, + onSuccess: (res) => { + const isRerun = selectedIds.length > 0; + if (isRerun) { + setSelectedIds([]); + } + queryClient.invalidateQueries({ queryKey: ['images', id] }); + message.open({ + key: 'screenshots-ocr', + type: 'success', + content: res.message, + }); + }, + onError: (_err, _vars, ctx) => { + const rollbackIds = ctx?.targetIds || []; + if (rollbackIds.length > 0) { + setRerunTracking((prev) => { + const next = { ...prev }; + rollbackIds.forEach((imageId) => { + delete next[imageId]; + }); + return next; + }); + message.open({ + key: 'screenshots-ocr', + type: 'error', + content: '选中图片 OCR 重跑提交失败', + }); + return; + } + message.open({ + key: 'screenshots-ocr', + type: 'error', + content: 'OCR任务提交失败', + }); + }, + }); + const { data: imageDetail } = useQuery({ + queryKey: ['image-detail', selectedImage?.id], + queryFn: () => fetchImageDetail(selectedImage!.id), + enabled: drawerOpen && !!selectedImage?.id, }); const filtered = @@ -82,15 +269,125 @@ const Screenshots: React.FC = () => { setSelectedImage(img); setDrawerOpen(true); }; + const resolveOcrStatus = (imageId: string, backendStatus: EvidenceImage['ocrStatus']): EvidenceImage['ocrStatus'] => { + const tracking = rerunTracking[imageId]; + if (!tracking) return backendStatus; + if (backendStatus === 'failed') return 'failed'; + if (backendStatus === 'done' && tracking.sawProcessing) return 'done'; + return 'processing'; + }; + React.useEffect(() => { + if (Object.keys(rerunTracking).length === 0) return; + const statusById = new Map(allImages.map((img) => [img.id, img.ocrStatus] as const)); + setRerunTracking((prev) => { + let changed = false; + const next: Record = { ...prev }; + Object.entries(prev).forEach(([imageId, tracking]) => { + const status = statusById.get(imageId); + if (!status) { + delete next[imageId]; + changed = true; + return; + } + if (status === 'processing' && !tracking.sawProcessing) { + next[imageId] = { sawProcessing: true }; + changed = true; + return; + } + if (status === 'failed' || (status === 'done' && tracking.sawProcessing)) { + delete next[imageId]; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [allImages, rerunTracking]); + const toggleChecked = (imageId: string, checked: boolean) => { + setSelectedIds((prev) => (checked ? Array.from(new Set([...prev, imageId])) : prev.filter((id) => id !== imageId))); + }; + const selectAllFiltered = () => { + setSelectedIds(Array.from(new Set([...selectedIds, ...filtered.map((img) => img.id)]))); + }; + const clearSelection = () => setSelectedIds([]); + const rawBlocks = imageDetail?.ocrBlocks || []; + const visibleBlocks = rawBlocks.filter((b) => { + const txt = (b.content || '').trim(); + return txt !== '' && txt !== '{}' && txt !== '[]'; + }); + const getEmptyReason = () => { + const status = (imageDetail || selectedImage)?.ocrStatus; + if (!status) return ''; + if (status === 'pending') return '该截图尚未开始 OCR 识别,请先触发识别。'; + if (status === 'processing') return 'OCR 正在进行中,稍后会自动刷新结果。'; + if (status === 'failed') return 'OCR 识别失败,请尝试重新识别或检查截图清晰度。'; + if (status === 'done' && rawBlocks.length === 0) { + return 'OCR 已执行完成,但未提取到可用文本块。常见原因:截图内容非交易明细、清晰度不足或遮挡。'; + } + if (status === 'done' && rawBlocks.length > 0 && visibleBlocks.length === 0) { + return 'OCR 返回了空结构(如 {} / []),未形成可展示字段;可尝试重新识别或更换更清晰截图。'; + } + return ''; + }; + useEffect(() => { + if (!drawerOpen) return; + setEditableTxs(buildEditableTransactions(visibleBlocks.map((b) => ({ id: b.id, content: b.content })))); + }, [drawerOpen, selectedImage?.id, imageDetail?.id, imageDetail?.ocrBlocks]); - const mockOcrFields = [ - { label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 }, - { label: '交易金额', value: '¥50,000.00', confidence: 0.99 }, - { label: '交易方向', value: '支出', confidence: 0.95 }, - { label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 }, - { label: '订单号', value: 'AL20260306002', confidence: 0.96 }, - { label: '备注', value: '投资款', confidence: 0.92 }, - ]; + const updateTxField = (txId: string, field: string, value: unknown) => { + setEditableTxs((prev) => + prev.map((tx) => { + if (tx.id !== txId) return tx; + const nextData = { ...tx.data, [field]: value }; + return { + ...tx, + data: nextData, + jsonText: JSON.stringify(nextData, null, 2), + jsonError: undefined, + }; + }), + ); + }; + + const updateTxJson = (txId: string, text: string) => { + setEditableTxs((prev) => + prev.map((tx) => { + if (tx.id !== txId) return tx; + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...tx, jsonText: text, jsonError: 'JSON 顶层必须是对象' }; + } + return { + ...tx, + jsonText: text, + jsonError: undefined, + data: parsed as Record, + }; + } catch { + return { ...tx, jsonText: text, jsonError: 'JSON 格式错误' }; + } + }), + ); + }; + + const getTxSummary = (tx: EditableTx, index: number) => { + const tradeTime = String(tx.data.trade_time ?? tx.data.tradeTime ?? '-'); + const amount = tx.data.amount; + return `#${index + 1} 时间:${tradeTime} 金额:${formatMoney(amount)}`; + }; + + const buildFieldRows = (tx: EditableTx) => { + const keys = Object.keys(tx.data); + const ordered = [ + ...preferredFieldOrder.filter((k) => keys.includes(k)), + ...keys.filter((k) => !preferredFieldOrder.includes(k as (typeof preferredFieldOrder)[number])), + ]; + return ordered.map((key) => ({ + key, + label: fieldLabelMap[key] || key, + value: tx.data[key], + })); + }; return (
@@ -104,6 +401,18 @@ const Screenshots: React.FC = () => { } extra={ + + + + {selectedIds.length > 0 && 已选 {selectedIds.length} 张}
{ + if (row.key === 'direction') { + return ( + updateTxField(tx.id, row.key, e.target.value)} + /> + ); + }, + }, + ]} + /> +
+ JSON(可编辑) + updateTxJson(tx.id, e.target.value)} + style={{ marginTop: 8, fontFamily: 'monospace' }} + /> + {tx.jsonError && ( + {tx.jsonError} + )} +
+ + ), + }))} + /> + )} @@ -292,7 +688,7 @@ const Screenshots: React.FC = () => { {selectedImage.id} - {selectedImage.hash} + {selectedImage.fileHash} {selectedImage.uploadedAt} diff --git a/frontend/src/pages/transactions/Transactions.tsx b/frontend/src/pages/transactions/Transactions.tsx index 7f209f5..5355bc0 100644 --- a/frontend/src/pages/transactions/Transactions.tsx +++ b/frontend/src/pages/transactions/Transactions.tsx @@ -124,7 +124,7 @@ const Transactions: React.FC = () => { render: (_, r) => ( {r.isDuplicate && ( - + 重复 )} @@ -219,7 +219,7 @@ const Transactions: React.FC = () => { {duplicateCount > 0 && ( { const { id = '1' } = useParams(); const navigate = useNavigate(); - const [currentStep, setCurrentStep] = useState(3); + const queryClient = useQueryClient(); + const { message } = App.useApp(); + const [uploadingCount, setUploadingCount] = useState(0); const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) }); const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) }); const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) }); const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) }); + const ocrMutation = useMutation({ + mutationFn: () => startCaseOcr(id, false), + onMutate: () => { + message.open({ + key: 'workspace-ocr', + type: 'loading', + content: '正在提交 OCR 任务...', + duration: 0, + }); + }, + onSuccess: (res) => { + message.open({ + key: 'workspace-ocr', + type: 'success', + content: res.message, + }); + queryClient.invalidateQueries({ queryKey: ['images', id] }); + }, + onError: () => { + message.open({ + key: 'workspace-ocr', + type: 'error', + content: 'OCR任务提交失败', + }); + }, + }); + const analysisMutation = useMutation({ + mutationFn: () => triggerAnalysis(id), + onMutate: () => { + message.open({ + key: 'workspace-analysis', + type: 'loading', + content: '正在提交案件分析任务...', + duration: 0, + }); + }, + onSuccess: (res) => { + message.open({ + key: 'workspace-analysis', + type: 'success', + content: res.message || '分析任务已提交', + }); + queryClient.invalidateQueries({ queryKey: ['assessments', id] }); + queryClient.invalidateQueries({ queryKey: ['suggestions', id] }); + queryClient.invalidateQueries({ queryKey: ['transactions', id] }); + queryClient.invalidateQueries({ queryKey: ['flows', id] }); + queryClient.invalidateQueries({ queryKey: ['case', id] }); + }, + onError: () => { + message.open({ + key: 'workspace-analysis', + type: 'error', + content: '案件分析提交失败', + }); + }, + }); const images = imagesData ?? []; const txList = txData?.items ?? []; @@ -49,6 +109,16 @@ const Workspace: React.FC = () => { const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length; const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length; + const currentStep = useMemo(() => { + if (images.length === 0) return 0; + const doneCount = images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length; + if (doneCount < images.length) return 1; + if (txList.length === 0) return 2; + if (assessments.length === 0) return 3; + if (pendingReview > 0) return 4; + return 5; + }, [images, txList.length, assessments.length, pendingReview]); + if (!currentCase) return null; const steps = [ @@ -60,7 +130,7 @@ const Workspace: React.FC = () => { { title: 'OCR识别', icon: , - description: `${images.filter((i: any) => i.ocrStatus === 'done').length}/${images.length} 已完成`, + description: `${images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length}/${images.length} 已完成`, }, { title: '交易归并', @@ -70,7 +140,7 @@ const Workspace: React.FC = () => { { title: '资金分析', icon: , - description: '已生成路径图', + description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析', }, { title: '认定复核', @@ -117,7 +187,6 @@ const Workspace: React.FC = () => { setCurrentStep(v)} /> @@ -136,10 +205,41 @@ const Workspace: React.FC = () => { multiple accept="image/*" showUploadList={false} - beforeUpload={(file, fileList) => { - uploadImages(id, fileList as unknown as File[]) - .then(() => message.success('截图上传成功')) - .catch(() => message.error('上传失败')); + beforeUpload={(file) => { + setUploadingCount((c) => c + 1); + message.open({ + key: 'img-upload', + type: 'loading', + content: `正在上传截图(队列中 ${uploadingCount + 1} 张)...`, + duration: 0, + }); + uploadImages(id, [file as File]) + .then(() => { + message.success('截图上传成功'); + }) + .then(() => { + queryClient.invalidateQueries({ queryKey: ['images', id] }); + queryClient.invalidateQueries({ queryKey: ['case', id] }); + }) + .catch(() => { + message.error('上传失败'); + }) + .finally(() => { + setUploadingCount((c) => { + const next = Math.max(0, c - 1); + if (next === 0) { + message.destroy('img-upload'); + } else { + message.open({ + key: 'img-upload', + type: 'loading', + content: `正在上传截图(队列中 ${next} 张)...`, + duration: 0, + }); + } + return next; + }); + }); return false; }} style={{ padding: '20px 0' }} @@ -154,6 +254,11 @@ const Workspace: React.FC = () => { 支持微信、支付宝、银行APP、数字钱包等多种来源截图

+ {uploadingCount > 0 && ( + + 当前有 {uploadingCount} 张截图正在上传,请稍候... + + )} @@ -163,7 +268,7 @@ const Workspace: React.FC = () => { i.ocrStatus === 'done').length / + images.length ? (images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length / images.length) * 100 : 0, )} @@ -243,29 +348,28 @@ const Workspace: React.FC = () => { - - + diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index f161395..3781aa1 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import React, { lazy, Suspense } from 'react'; import type { RouteObject } from 'react-router-dom'; import { Navigate } from 'react-router-dom'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 66b6c70..8493ded 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,9 +8,12 @@ import type { CaseRecord, EvidenceImage, + EvidenceImageDetail, TransactionRecord, FraudAssessment, ExportReport, + FundFlowNode, + FundFlowEdge, } from '../types'; import { mockCases, @@ -36,20 +39,30 @@ async function request(url: string, options?: RequestInit): Promise { // ── helpers ── let backendAlive: boolean | null = null; +let lastCheckTime = 0; +const CHECK_TTL_MS = 10_000; // re-check every 10s if previously failed async function isBackendUp(): Promise { - if (backendAlive !== null) return backendAlive; + const now = Date.now(); + if (backendAlive === true && now - lastCheckTime < 60_000) return true; + if (backendAlive === false && now - lastCheckTime < CHECK_TTL_MS) return false; try { - const r = await fetch('/health', { signal: AbortSignal.timeout(2000) }); + const r = await fetch('/api/v1/cases?limit=1', { signal: AbortSignal.timeout(2000) }); backendAlive = r.ok; } catch { backendAlive = false; } + lastCheckTime = now; return backendAlive; } export function resetBackendCheck() { backendAlive = null; + lastCheckTime = 0; +} + +export async function getDataSourceMode(): Promise<'mock' | 'api'> { + return (await isBackendUp()) ? 'api' : 'mock'; } // ── Cases ── @@ -106,17 +119,51 @@ export async function uploadImages(caseId: string, files: File[]): Promise { + if (!(await isBackendUp())) { + const fallback = mockImages.find((i) => i.id === imageId) || mockImages[0]; + return { + ...fallback, + ocrBlocks: [], + }; + } + return request(`${BASE}/images/${imageId}`); +} + +export async function startCaseOcr( + caseId: string, + includeDone = false, + imageIds?: string[], +): Promise<{ caseId: string; submitted: number; totalCandidates: number; message: string }> { + if (!(await isBackendUp())) { + return { + caseId, + submitted: imageIds?.length || 0, + totalCandidates: imageIds?.length || 0, + message: 'Mock 模式下不执行真实 OCR 任务', + }; + } + const result = await request<{ caseId: string; submitted: number; totalCandidates: number; message: string }>(`${BASE}/cases/${caseId}/ocr/start`, { + method: 'POST', + body: JSON.stringify({ + include_done: includeDone, + image_ids: imageIds && imageIds.length ? imageIds : undefined, + }), + }); + return result; +} + // ── Analysis ── -export async function triggerAnalysis(caseId: string): Promise<{ task_id: string; message: string }> { - if (!(await isBackendUp())) return { task_id: 'mock', message: '分析任务已提交' }; +export async function triggerAnalysis(caseId: string): Promise<{ taskId: string; message: string }> { + if (!(await isBackendUp())) return { taskId: 'mock', message: '分析任务已提交' }; return request(`${BASE}/cases/${caseId}/analyze`, { method: 'POST' }); } export async function fetchAnalysisStatus( caseId: string, -): Promise<{ status: string; progress: number; current_step: string }> { - if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, current_step: '待复核' }; +): Promise<{ status: string; progress: number; currentStep: string }> { + if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, currentStep: '待复核' }; return request(`${BASE}/cases/${caseId}/analyze/status`); } @@ -135,7 +182,7 @@ export async function fetchTransactions( export async function fetchFlows( caseId: string, -): Promise<{ nodes: any[]; edges: any[] }> { +): Promise<{ nodes: FundFlowNode[]; edges: FundFlowEdge[] }> { if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges }; return request(`${BASE}/cases/${caseId}/flows`); } @@ -153,7 +200,7 @@ export async function fetchAssessments( export async function submitReview( assessmentId: string, - body: { review_status: string; review_note?: string; reviewed_by?: string }, + body: { review_status: 'confirmed' | 'rejected' | 'needs_info'; review_note?: string; reviewed_by?: string }, ): Promise { if (!(await isBackendUp())) return mockAssessments[0]; return request(`${BASE}/assessments/${assessmentId}/review`, { @@ -178,7 +225,16 @@ export async function fetchInquirySuggestions(caseId: string): Promise<{ suggest export async function generateReport( caseId: string, - body: { report_type: string }, + body: { + report_type: string; + include_summary?: boolean; + include_transactions?: boolean; + include_flow_chart?: boolean; + include_timeline?: boolean; + include_reasons?: boolean; + include_inquiry?: boolean; + include_screenshots?: boolean; + }, ): Promise { if (!(await isBackendUp())) return mockReports[0]; return request(`${BASE}/cases/${caseId}/reports`, { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eea0fa1..7509e3f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -24,10 +24,22 @@ export interface EvidenceImage { sourceApp: SourceApp; pageType: PageType; ocrStatus: 'pending' | 'processing' | 'done' | 'failed'; - hash: string; + fileHash: string; uploadedAt: string; } +export interface OcrBlock { + id: string; + content: string; + bbox: Record; + seqOrder: number; + confidence: number; +} + +export interface EvidenceImageDetail extends EvidenceImage { + ocrBlocks: OcrBlock[]; +} + export interface TransactionRecord { id: string; caseId: string; @@ -81,8 +93,8 @@ export interface FundFlowEdge { export interface ExportReport { id: string; caseId: string; - type: 'pdf' | 'excel' | 'word'; - url: string; + reportType: 'pdf' | 'excel' | 'word'; + filePath: string; createdAt: string; version: number; }