update: fix-02

This commit is contained in:
2026-03-13 09:57:04 +08:00
parent 7cd2a18364
commit e0a40ceff0
10 changed files with 843 additions and 133 deletions

View File

@@ -72,19 +72,32 @@ bash infra/scripts/start-dev.sh
### 手动启动
```bash
# 1. 启动基础设施
cd infra/docker && docker compose up -d
# 1. 启动基础设施PostgreSQL + Redis
cd infra/docker
docker compose up -d
cd ../..
# 2. 安装并启动后端
# 2. 安装后端依赖
cd backend
pip install -e ".[dev]"
cp ../infra/env/.env.example .env # 按需编辑
python -m venv .venv # 创建虚拟环境(推荐)
source .venv/bin/activate # macOS/Linux
# .venv\Scripts\activate # Windows
pip install -r requirements.txt # 安装依赖
# 3. 配置环境变量
cp ../infra/env/.env.example .env # 复制模板,按需编辑
# 4. 初始化数据库
alembic revision --autogenerate -m "init"
alembic upgrade head
python -m scripts.seed # 插入演示数据
uvicorn app.main:app --reload --port 8000
# 3. 安装并启动前端
# 5. 插入演示数据
python -m scripts.seed
# 6. 启动后端服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 7. 安装并启动前端(新开终端)
cd frontend
npm install
npm run dev
@@ -106,17 +119,23 @@ cd frontend && npm run dev
## 配置 AI 能力
`backend/.env` 中配置:
系统使用两组独立的 OpenAI 兼容接口,`backend/.env` 中配置:
```
```bash
# OCR — 截图识别与字段抽取(需要多模态/视觉能力)
OCR_API_KEY=your_key
OCR_API_URL=https://api.example.com/v1/chat/completions
OCR_MODEL=gpt-4o
# LLM — 认定理由生成、问询建议等推理任务
LLM_API_KEY=your_key
LLM_API_URL=https://api.example.com/v1/chat/completions
LLM_MODEL=model_name
LLM_MODEL=gpt-4o-mini
```
支持 OpenAI 兼容格式的多模态 API。未配置时自动使用 mock 数据。
- OCR 和 LLM 可以指向不同的供应商/模型(如 OCR 用视觉模型LLM 用轻量文本模型)
- 如果只配置 LLM 而未配置 OCROCR 会自动降级使用 LLM 的配置
- 两者均未配置时自动使用 mock 数据,不影响演示
## 测试

View File

@@ -1,5 +1,6 @@
from uuid import UUID
import asyncio
from sqlalchemy import delete, select
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
@@ -10,7 +11,13 @@ from app.core.database import get_db
from app.models.evidence_image import EvidenceImage, SourceApp, PageType, OcrStatus
from app.repositories.image_repo import ImageRepository
from app.repositories.case_repo import CaseRepository
from app.schemas.image import ImageOut, ImageDetailOut, OcrFieldCorrection, CaseOcrStartIn
from app.schemas.image import (
ImageOut,
ImageDetailOut,
OcrFieldCorrection,
CaseOcrStartIn,
CaseImagesDeleteIn,
)
from app.utils.hash import sha256_file
from app.utils.file_storage import save_upload
@@ -172,6 +179,12 @@ async def start_case_ocr(
await db.commit()
else:
images = await repo.list_for_ocr(case_id, include_done=include_done)
# Mark queued images as processing immediately, including when OCR is
# triggered from workspace page, so UI can show progress right away.
for img in images:
img.ocr_status = OcrStatus.processing
await db.flush()
await db.commit()
from app.workers.ocr_tasks import process_images_ocr_batch_async
@@ -190,3 +203,70 @@ async def start_case_ocr(
"totalCandidates": len(images),
"message": f"已提交 {submitted} 张截图的 OCR 任务",
}
@router.delete("/cases/{case_id}/images")
async def delete_case_images(
case_id: UUID,
payload: CaseImagesDeleteIn,
db: AsyncSession = Depends(get_db),
):
case_repo = CaseRepository(db)
case = await case_repo.get(case_id)
if not case:
raise HTTPException(404, "案件不存在")
if not payload.image_ids:
return {"caseId": str(case_id), "deleted": 0, "message": "未选择需要删除的截图"}
repo = ImageRepository(db)
images = await repo.list_by_ids_in_case(case_id, payload.image_ids)
if not images:
return {"caseId": str(case_id), "deleted": 0, "message": "未找到可删除的截图"}
from app.models.ocr_block import OcrBlock
from app.models.transaction import TransactionRecord
from app.models.assessment import FraudAssessment
deleted = 0
try:
for image in images:
# remove related OCR blocks and extracted transactions first
# assessments reference transaction_records.transaction_id, so they
# must be deleted before deleting transaction records.
await db.execute(
delete(FraudAssessment).where(
FraudAssessment.transaction_id.in_(
select(TransactionRecord.id).where(
TransactionRecord.evidence_image_id == image.id
)
)
)
)
await db.execute(delete(OcrBlock).where(OcrBlock.image_id == image.id))
await db.execute(delete(TransactionRecord).where(TransactionRecord.evidence_image_id == image.id))
await repo.delete(image)
deleted += 1
# best-effort remove local files
for rel in [image.file_path, image.thumb_path]:
if rel:
try:
p = settings.upload_path / rel
if p.exists():
p.unlink()
except Exception:
pass
case.image_count = await repo.count_by_case(case_id)
await db.flush()
await db.commit()
except Exception as e:
await db.rollback()
raise
return {
"caseId": str(case_id),
"deleted": deleted,
"message": f"已删除 {deleted} 张截图",
}

View File

@@ -38,3 +38,7 @@ class OcrFieldCorrection(CamelModel):
class CaseOcrStartIn(CamelModel):
include_done: bool = False
image_ids: list[UUID] = []
class CaseImagesDeleteIn(CamelModel):
image_ids: list[UUID] = []

View File

@@ -36,6 +36,15 @@ const statusConfig: Record<CaseStatus, { color: string; label: string }> = {
completed: { color: 'green', label: '已完成' },
};
const splitDateTime = (raw: string): { date: string; time: string } => {
if (!raw) return { date: '-', time: '-' };
const normalized = raw.trim().replace(' ', 'T');
if (!normalized.includes('T')) return { date: normalized, time: '-' };
const [datePart, timePartRaw = ''] = normalized.split('T');
const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-';
return { date: datePart || '-', time: cleanedTime };
};
const CaseList: React.FC = () => {
const navigate = useNavigate();
const qc = useQueryClient();
@@ -71,18 +80,19 @@ const CaseList: React.FC = () => {
{
title: '案件编号',
dataIndex: 'caseNo',
width: 180,
width: 120,
ellipsis: true,
render: (text, record) => (
<a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a>
),
},
{ title: '案件名称', dataIndex: 'title', ellipsis: true },
{ title: '受害人', dataIndex: 'victimName', width: 100 },
{ title: '承办人', dataIndex: 'handler', width: 100 },
{ title: '案件名称', dataIndex: 'title', width: 140, ellipsis: true },
{ title: '受害人', dataIndex: 'victimName', width: 80 },
{ title: '承办人', dataIndex: 'handler', width: 80 },
{
title: '状态',
dataIndex: 'status',
width: 100,
width: 60,
render: (s: CaseStatus) => (
<Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag>
),
@@ -96,7 +106,7 @@ const CaseList: React.FC = () => {
{
title: '识别金额(元)',
dataIndex: 'totalAmount',
width: 140,
width: 120,
align: 'right',
render: (v: number) =>
v > 0 ? (
@@ -110,11 +120,23 @@ const CaseList: React.FC = () => {
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
width: 110,
render: (raw: string) => {
const { date, time } = splitDateTime(raw);
return (
<div style={{ lineHeight: 1.2 }}>
<Typography.Text style={{ fontSize: 12 }}>{date}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{time}
</Typography.Text>
</div>
);
},
},
{
title: '操作',
width: 160,
width: 108,
render: (_, record) => (
<Space>
<Button
@@ -207,6 +229,7 @@ const CaseList: React.FC = () => {
columns={columns}
dataSource={cases}
loading={isLoading}
scroll={{ x: 'max-content' }}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `${t}` }}
/>
</Card>

View File

@@ -67,6 +67,15 @@ const saveContentSelection = (caseId: string, sel: Record<ContentKeys, boolean>)
} catch { /* ignore */ }
};
const splitDateTime = (raw: string): { date: string; time: string } => {
if (!raw) return { date: '-', time: '-' };
const normalized = raw.trim().replace(' ', 'T');
if (!normalized.includes('T')) return { date: normalized, time: '-' };
const [datePart, timePartRaw = ''] = normalized.split('T');
const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-';
return { date: datePart || '-', time: cleanedTime };
};
const Reports: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
@@ -156,7 +165,19 @@ const Reports: React.FC = () => {
{
title: '生成时间',
dataIndex: 'createdAt',
width: 180,
width: 110,
render: (raw: string) => {
const { date, time } = splitDateTime(raw);
return (
<div style={{ lineHeight: 1.2 }}>
<Typography.Text style={{ fontSize: 12 }}>{date}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{time}
</Typography.Text>
</div>
);
},
},
{
title: '操作',

View File

@@ -11,6 +11,7 @@ import {
Button,
Drawer,
Input,
InputNumber,
Select,
Descriptions,
Row,
@@ -70,9 +71,68 @@ const aiSuggestionLabel: Record<ReviewAction, string> = {
needs_info: '需补充调查',
};
const getAiSuggestedAction = (assessment: FraudAssessment): ReviewAction => {
const BRUSHING_KEYWORDS = ['刷单', '淘宝', '店铺', '商家', '客服', '任务', '返利', '垫付', '做单', '佣金'];
const normalizeText = (value?: string): string =>
(value || '').replace(/\s+/g, '').toLowerCase();
const hasBrushingSignals = (assessment: FraudAssessment): boolean => {
const text = normalizeText(
`${assessment.transaction.counterpartyName} ${assessment.transaction.remark} ${assessment.reason}`,
);
return BRUSHING_KEYWORDS.some((kw) => text.includes(kw));
};
const hasNearbySimilarPattern = (
assessment: FraudAssessment,
allAssessments: FraudAssessment[],
): boolean => {
const current = assessment.transaction;
const currentTime = Date.parse(current.tradeTime);
if (!Number.isFinite(currentTime) || current.direction !== 'out' || current.amount <= 0) return false;
const currentCounterparty = normalizeText(current.counterpartyName);
const currentHasKeyword = BRUSHING_KEYWORDS.some((kw) => currentCounterparty.includes(kw));
const similarCount = allAssessments.filter((item) => {
if (item.id === assessment.id) return false;
const tx = item.transaction;
if (tx.direction !== 'out' || tx.amount <= 0) return false;
const otherTime = Date.parse(tx.tradeTime);
if (!Number.isFinite(otherTime)) return false;
const minutesGap = Math.abs(otherTime - currentTime) / 60000;
if (minutesGap > 15) return false;
const amountRatio = Math.abs(tx.amount - current.amount) / Math.max(current.amount, tx.amount);
if (amountRatio > 0.15) return false;
const otherCounterparty = normalizeText(tx.counterpartyName);
const sameCounterparty =
!!currentCounterparty &&
!!otherCounterparty &&
(currentCounterparty.includes(otherCounterparty) ||
otherCounterparty.includes(currentCounterparty));
const sharedKeyword =
currentHasKeyword &&
BRUSHING_KEYWORDS.some((kw) => otherCounterparty.includes(kw));
return sameCounterparty || sharedKeyword;
}).length;
return similarCount >= 1;
};
const getAiSuggestedAction = (
assessment: FraudAssessment,
allAssessments: FraudAssessment[],
): ReviewAction => {
if (assessment.assessedAmount <= 0 || assessment.confidenceLevel === 'low') return 'rejected';
if (assessment.confidenceLevel === 'high') return 'confirmed';
if (assessment.confidenceLevel === 'medium') {
if (hasBrushingSignals(assessment) || hasNearbySimilarPattern(assessment, allAssessments)) {
return 'confirmed';
}
}
return 'needs_info';
};
@@ -91,6 +151,7 @@ const Review: React.FC = () => {
const { message } = App.useApp();
const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null);
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
const [reviewNote, setReviewNote] = useState('');
@@ -240,7 +301,7 @@ const Review: React.FC = () => {
title: '状态',
width: 74,
render: (_, r) => {
const aiAction = getAiSuggestedAction(r);
const aiAction = getAiSuggestedAction(r, allAssessments);
const isPending = r.reviewStatus === 'pending';
const status = isPending ? aiAction : r.reviewStatus;
@@ -378,7 +439,8 @@ const Review: React.FC = () => {
icon={<EyeOutlined />}
onClick={() => {
setReviewModal(r);
setReviewAction(getAiSuggestedAction(r));
setEditableTx({ ...r.transaction });
setReviewAction(getAiSuggestedAction(r, allAssessments));
setReviewNote('');
}}
>
@@ -564,10 +626,13 @@ const Review: React.FC = () => {
<Drawer
title="查看 / 复核"
open={!!reviewModal}
onClose={() => setReviewModal(null)}
onClose={() => {
setReviewModal(null);
setEditableTx(null);
}}
width={720}
>
{reviewModal && (
{reviewModal && editableTx && (
<>
<Row gutter={16} align="top">
<Col span={10}>
@@ -601,18 +666,103 @@ const Review: React.FC = () => {
<Col span={14}>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="交易时间">
{reviewModal.transaction.tradeTime}
<Input
value={editableTx.tradeTime ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, tradeTime: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
<InputNumber
style={{ width: '100%' }}
value={editableTx.amount}
onChange={(val) =>
setEditableTx((prev) => (prev ? { ...prev, amount: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="对方">
{reviewModal.transaction.counterpartyName}
<Input
value={editableTx.counterpartyName ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, counterpartyName: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="对方账号">
<Input
value={editableTx.counterpartyAccount ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, counterpartyAccount: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
<Input
value={editableTx.selfAccountTailNo ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, selfAccountTailNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="订单号">
<Input
value={editableTx.orderNo ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, orderNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="交易方向">
<Select
style={{ width: '100%' }}
value={editableTx.direction}
onChange={(val) =>
setEditableTx((prev) => (prev ? { ...prev, direction: val } : prev))
}
options={[
{ label: '转入', value: 'in' },
{ label: '转出', value: 'out' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="来源APP">
{reviewModal.transaction.sourceApp}
<Select
style={{ width: '100%' }}
value={editableTx.sourceApp}
onChange={(val) =>
setEditableTx((prev) => (prev ? { ...prev, sourceApp: val } : prev))
}
options={[
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行', value: 'bank' },
{ label: '数字钱包', value: 'digital_wallet' },
{ label: '其他', value: 'other' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="备注">
<Input.TextArea
rows={2}
value={editableTx.remark ?? ''}
onChange={(e) =>
setEditableTx((prev) => (prev ? { ...prev, remark: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="置信度">
<InputNumber
style={{ width: '100%' }}
min={0}
max={1}
step={0.01}
value={editableTx.confidence}
onChange={(val) =>
setEditableTx((prev) => (prev ? { ...prev, confidence: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="置信等级">
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
@@ -620,7 +770,7 @@ const Review: React.FC = () => {
</Tag>
</Descriptions.Item>
<Descriptions.Item label="AI建议">
<Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}</Tag>
<Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal, allAssessments)]}</Tag>
</Descriptions.Item>
</Descriptions>
@@ -644,7 +794,7 @@ const Review: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong></Typography.Text>
<Typography.Text type="secondary">
AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}
AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal, allAssessments)]}
</Typography.Text>
<Select
value={reviewAction}
@@ -681,7 +831,12 @@ const Review: React.FC = () => {
</Row>
<Divider />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setReviewModal(null)}>
<Button
onClick={() => {
setReviewModal(null);
setEditableTx(null);
}}
>
</Button>
{reviewModal.reviewStatus === 'pending' && (

View File

@@ -31,9 +31,10 @@ import {
CloseCircleOutlined,
ZoomInOutlined,
PlayCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
import { deleteCaseImages, fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
@@ -182,11 +183,49 @@ const Screenshots: React.FC = () => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({});
const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]);
const [lastProcessingCount, setLastProcessingCount] = useState(0);
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
refetchInterval: Object.keys(rerunTracking).length > 0 ? 2000 : false,
refetchInterval: (query) => {
const images = (query.state.data as EvidenceImage[] | undefined) ?? [];
const hasBackendProcessing = images.some((img) => img.ocrStatus === 'processing');
return hasBackendProcessing || Object.keys(rerunTracking).length > 0 ? 2000 : false;
},
});
const deleteMutation = useMutation({
mutationFn: (targetIds: string[]) => deleteCaseImages(id, targetIds),
onMutate: () => {
message.open({
key: 'screenshots-delete',
type: 'loading',
content: `正在删除选中截图(${selectedIds.length}...`,
duration: 0,
});
},
onSuccess: (res, targetIds) => {
message.open({
key: 'screenshots-delete',
type: 'success',
content: res.message,
});
setSelectedIds((prev) => prev.filter((x) => !targetIds.includes(x)));
if (selectedImage && targetIds.includes(selectedImage.id)) {
setDrawerOpen(false);
setSelectedImage(null);
}
queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['transactions', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
},
onError: () => {
message.open({
key: 'screenshots-delete',
type: 'error',
content: '删除截图失败',
});
},
});
const triggerOcrMutation = useMutation({
mutationFn: (targetIds: string[]) =>
@@ -276,6 +315,15 @@ const Screenshots: React.FC = () => {
if (backendStatus === 'done' && tracking.sawProcessing) return 'done';
return 'processing';
};
React.useEffect(() => {
const processingCount = allImages.filter(
(img) => resolveOcrStatus(img.id, img.ocrStatus) === 'processing',
).length;
if (lastProcessingCount > 0 && processingCount === 0) {
message.success('OCR识别已完成');
}
setLastProcessingCount(processingCount);
}, [allImages, rerunTracking, lastProcessingCount, message]);
React.useEffect(() => {
if (Object.keys(rerunTracking).length === 0) return;
const statusById = new Map(allImages.map((img) => [img.id, img.ocrStatus] as const));
@@ -408,7 +456,16 @@ const Screenshots: React.FC = () => {
disabled={selectedIds.length === 0}
onClick={() => triggerOcrMutation.mutate(selectedIds)}
>
{selectedIds.length > 0 ? `对选中图片重新OCR${selectedIds.length}` : '开始 OCR 识别'}
OCR
</Button>
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
disabled={selectedIds.length === 0}
onClick={() => deleteMutation.mutate(selectedIds)}
>
</Button>
<Button onClick={selectAllFiltered}></Button>
<Button onClick={clearSelection} disabled={selectedIds.length === 0}></Button>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@@ -17,6 +17,9 @@ import {
Row,
Col,
Statistic,
Input,
InputNumber,
Dropdown,
} from 'antd';
import {
SwapOutlined,
@@ -28,7 +31,7 @@ import {
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types';
import { fetchTransactions } from '../../services/api';
import { fetchImageDetail, fetchTransactions } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
@@ -38,45 +41,84 @@ const appTag: Record<SourceApp, { label: string; color: string }> = {
other: { label: '其他', color: 'default' },
};
const splitDateTime = (raw: string): { date: string; time: string } => {
if (!raw) return { date: '-', time: '-' };
const normalized = raw.trim().replace(' ', 'T');
if (!normalized.includes('T')) return { date: normalized, time: '-' };
const [datePart, timePartRaw = ''] = normalized.split('T');
const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-';
return { date: datePart || '-', time: cleanedTime };
};
const Transactions: React.FC = () => {
const { id = '1' } = useParams();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null);
const [editableDetail, setEditableDetail] = useState<TransactionRecord | null>(null);
const [markOverrides, setMarkOverrides] = useState<Record<string, 'duplicate' | 'transit' | 'valid'>>({});
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const allTransactions = txData?.items ?? [];
const { data: detailImage, isFetching: detailImageFetching } = useQuery({
queryKey: ['image-detail', detail?.evidenceImageId],
queryFn: () => fetchImageDetail(detail!.evidenceImageId),
enabled: !!detail?.evidenceImageId,
});
useEffect(() => {
setEditableDetail(detail ? { ...detail } : null);
}, [detail]);
const getEffectiveMark = (tx: TransactionRecord): 'duplicate' | 'transit' | 'valid' => {
if (markOverrides[tx.id]) return markOverrides[tx.id];
if (tx.isDuplicate) return 'duplicate';
if (tx.isTransit) return 'transit';
return 'valid';
};
const data =
filterDuplicate === 'all'
? allTransactions
: filterDuplicate === 'unique'
? allTransactions.filter((t) => !t.isDuplicate)
: allTransactions.filter((t) => t.isDuplicate);
? allTransactions.filter((t) => getEffectiveMark(t) !== 'duplicate')
: allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate');
const totalOut = allTransactions
.filter((t) => t.direction === 'out' && !t.isDuplicate)
.filter((t) => t.direction === 'out' && getEffectiveMark(t) !== 'duplicate')
.reduce((s, t) => s + t.amount, 0);
const totalIn = allTransactions
.filter((t) => t.direction === 'in' && !t.isDuplicate)
.filter((t) => t.direction === 'in' && getEffectiveMark(t) !== 'duplicate')
.reduce((s, t) => s + t.amount, 0);
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length;
const transitCount = allTransactions.filter((t) => t.isTransit).length;
const duplicateCount = allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate').length;
const transitCount = allTransactions.filter((t) => getEffectiveMark(t) === 'transit').length;
const columns: ColumnsType<TransactionRecord> = [
{
title: '交易时间',
dataIndex: 'tradeTime',
width: 170,
width: 96,
render: (raw: string) => {
const { date, time } = splitDateTime(raw);
return (
<div style={{ lineHeight: 1.2 }}>
<Typography.Text style={{ fontSize: 12 }}>{date}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{time}
</Typography.Text>
</div>
);
},
sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
dataIndex: 'sourceApp',
width: 100,
width: 80,
render: (app: SourceApp) => (
<Tag color={appTag[app].color}>{appTag[app].label}</Tag>
),
@@ -110,6 +152,7 @@ const Transactions: React.FC = () => {
{
title: '对方',
dataIndex: 'counterpartyName',
width: 120,
ellipsis: true,
},
{
@@ -120,23 +163,74 @@ const Transactions: React.FC = () => {
},
{
title: '标记',
width: 130,
width: 73,
render: (_, r) => (
<Space size={4}>
{r.isDuplicate && (
<Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并">
<Tag color="red"></Tag>
(() => {
const mark = getEffectiveMark(r);
const styleByMark: Record<'duplicate' | 'transit' | 'valid', { bg: string; border: string; color: string; label: string }> = {
duplicate: { bg: '#fff2e8', border: '#ffbb96', color: '#cf1322', label: '重复' },
transit: { bg: '#fff7e6', border: '#ffd591', color: '#d46b08', label: '中转' },
valid: { bg: '#f6ffed', border: '#b7eb8f', color: '#389e0d', label: '有效' },
};
const cfg = styleByMark[mark];
const options: Array<'duplicate' | 'transit' | 'valid'> = ['duplicate', 'transit', 'valid'];
return (
<Space.Compact size="small" style={{ width: '100%' }}>
<Tooltip
title={
mark === 'duplicate'
? '该笔与其他记录订单号一致,判定为同一笔展示记录并已归并'
: mark === 'transit'
? '该笔为本人账户间中转,不直接计入被骗金额'
: '该笔为有效交易'
}
>
<Button
size="small"
style={{
flex: 1,
background: cfg.bg,
color: cfg.color,
border: `1px solid ${cfg.border}`,
borderRight: 'none',
borderRadius: '6px 0 0 6px',
fontWeight: 600,
cursor: 'default',
}}
>
{cfg.label}
</Button>
</Tooltip>
)}
{r.isTransit && (
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额">
<Tag color="orange"></Tag>
</Tooltip>
)}
{!r.isDuplicate && !r.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
<Dropdown
menu={{
items: options
.filter((v) => v !== mark)
.map((v) => ({
key: v,
label: v === 'duplicate' ? '重复' : v === 'transit' ? '中转' : '有效',
})),
onClick: ({ key }) =>
setMarkOverrides((prev) => ({ ...prev, [r.id]: key as 'duplicate' | 'transit' | 'valid' })),
}}
trigger={['click']}
>
<Button
size="small"
style={{
background: cfg.bg,
color: cfg.color,
border: `1px solid ${cfg.border}`,
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
padding: '0 6px',
}}
>
</Button>
</Dropdown>
</Space.Compact>
);
})()
),
},
{
@@ -252,11 +346,12 @@ const Transactions: React.FC = () => {
rowKey="id"
columns={columns}
dataSource={data}
scroll={{ x: 'max-content' }}
pagination={false}
rowClassName={(r) =>
r.isDuplicate
getEffectiveMark(r) === 'duplicate'
? 'row-duplicate'
: r.isTransit
: getEffectiveMark(r) === 'transit'
? 'row-transit'
: ''
}
@@ -267,65 +362,156 @@ const Transactions: React.FC = () => {
<Drawer
title="交易详情"
placement="right"
width={480}
width={780}
open={!!detail}
onClose={() => setDetail(null)}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="交易时间">
{detail.tradeTime}
</Descriptions.Item>
<Descriptions.Item label="来源APP">
<Tag color={appTag[detail.sourceApp].color}>
{appTag[detail.sourceApp].label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text
strong
{detail && editableDetail && (
<Row gutter={16} align="top">
<Col span={10}>
<Card size="small" loading={detailImageFetching}>
<Typography.Text strong></Typography.Text>
<div
style={{
color: detail.direction === 'out' ? '#cf1322' : '#389e0d',
marginTop: 10,
height: 430,
background: '#fafafa',
border: '1px dashed #d9d9d9',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{detail.direction === 'out' ? '-' : '+'}¥
{detail.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
{detailImage?.url ? (
<img
src={detailImage.url}
alt="来源截图"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</Card>
</Col>
<Col span={14}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="交易时间">
<Input
value={editableDetail.tradeTime ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, tradeTime: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="来源APP">
<Select
style={{ width: '100%' }}
value={editableDetail.sourceApp}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, sourceApp: val } : prev))
}
options={[
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行', value: 'bank' },
{ label: '数字钱包', value: 'digital_wallet' },
{ label: '其他', value: 'other' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="金额">
<InputNumber
style={{ width: '100%' }}
value={editableDetail.amount}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, amount: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="方向">
<Select
style={{ width: '100%' }}
value={editableDetail.direction}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, direction: val } : prev))
}
options={[
{ label: '转入', value: 'in' },
{ label: '转出', value: 'out' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="对方">
{detail.counterpartyName}
<Input
value={editableDetail.counterpartyName ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyName: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="对方账号">
{detail.counterpartyAccount || '-'}
<Input
value={editableDetail.counterpartyAccount ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyAccount: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
{detail.selfAccountTailNo || '-'}
<Input
value={editableDetail.selfAccountTailNo ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, selfAccountTailNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="订单号">
{detail.orderNo}
<Input
value={editableDetail.orderNo ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, orderNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="备注">
<Input.TextArea
rows={2}
value={editableDetail.remark ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, remark: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="置信度">
{(detail.confidence * 100).toFixed(0)}%
</Descriptions.Item>
<Descriptions.Item label="证据截图">
<Button type="link" size="small" icon={<EyeOutlined />}>
({detail.evidenceImageId})
</Button>
<InputNumber
style={{ width: '100%' }}
min={0}
max={1}
step={0.01}
value={editableDetail.confidence}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, confidence: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="归并簇">
{detail.clusterId || '独立交易'}
<Input value={editableDetail.clusterId || ''} readOnly />
</Descriptions.Item>
<Descriptions.Item label="标记">
<Space>
{detail.isDuplicate && <Tag color="red"></Tag>}
{detail.isTransit && <Tag color="orange"></Tag>}
{!detail.isDuplicate && !detail.isTransit && (
{getEffectiveMark(detail) === 'duplicate' && <Tag color="red"></Tag>}
{getEffectiveMark(detail) === 'transit' && <Tag color="orange"></Tag>}
{getEffectiveMark(detail) === 'valid' && (
<Tag color="green"></Tag>
)}
</Space>
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
)}
</Drawer>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -16,6 +16,7 @@ import {
Descriptions,
Progress,
Alert,
Empty,
} from 'antd';
import {
CloudUploadOutlined,
@@ -34,12 +35,39 @@ import type { EvidenceImage } from '../../types';
const { Dragger } = Upload;
type UploadBatchItem = {
localId: string;
fileName: string;
fileSize: number;
status: 'uploading' | 'success' | 'error';
uploadedImageId?: string;
previewUrl?: string;
};
const formatFileSize = (size: number): string => {
if (!Number.isFinite(size) || size <= 0) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const Workspace: React.FC = () => {
const { id = '1' } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { message } = App.useApp();
const [uploadingCount, setUploadingCount] = useState(0);
const [uploadBatchItems, setUploadBatchItems] = useState<UploadBatchItem[]>([]);
const batchCounterRef = useRef<{ success: number; failed: number }>({ success: 0, failed: 0 });
const batchActiveRef = useRef(false);
useEffect(() => {
return () => {
uploadBatchItems.forEach((item) => {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
});
};
}, [uploadBatchItems]);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
@@ -206,29 +234,80 @@ const Workspace: React.FC = () => {
accept="image/*"
showUploadList={false}
beforeUpload={(file) => {
setUploadingCount((c) => c + 1);
if (!batchActiveRef.current) {
batchActiveRef.current = true;
batchCounterRef.current = { success: 0, failed: 0 };
setUploadBatchItems((prev) => {
prev.forEach((item) => {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
});
return [];
});
}
const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const previewUrl = URL.createObjectURL(file as File);
setUploadingCount((c) => {
const next = c + 1;
message.open({
key: 'img-upload',
type: 'loading',
content: `正在上传截图(队列中 ${uploadingCount + 1} 张)...`,
content: `正在上传截图(队列中 ${next} 张):${file.name}`,
duration: 0,
});
return next;
});
setUploadBatchItems((prev) => [
...prev,
{
localId,
fileName: file.name,
fileSize: file.size,
status: 'uploading',
previewUrl,
},
]);
uploadImages(id, [file as File])
.then(() => {
message.success('截图上传成功');
.then((uploaded) => {
const uploadedImage = uploaded?.[0];
setUploadBatchItems((prev) =>
prev.map((item) =>
item.localId === localId
? {
...item,
status: 'success',
uploadedImageId: uploadedImage?.id,
}
: item,
),
);
batchCounterRef.current.success += 1;
})
.then(() => {
queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
})
.catch(() => {
message.error('上传失败');
setUploadBatchItems((prev) =>
prev.map((item) =>
item.localId === localId
? { ...item, status: 'error' }
: item,
),
);
batchCounterRef.current.failed += 1;
})
.finally(() => {
setUploadingCount((c) => {
const next = Math.max(0, c - 1);
if (next === 0) {
batchActiveRef.current = false;
message.destroy('img-upload');
const summary = {
success: batchCounterRef.current.success,
failed: batchCounterRef.current.failed,
};
message.success(`本次上传完成:成功 ${summary.success} 张,失败 ${summary.failed}`);
} else {
message.open({
key: 'img-upload',
@@ -259,6 +338,75 @@ const Workspace: React.FC = () => {
{uploadingCount} ...
</Typography.Text>
)}
<Card
size="small"
title="本次上传清单"
style={{ marginTop: 12 }}
extra={
uploadBatchItems.length > 0 ? (
<Button
size="small"
type="link"
onClick={() => navigate(`/cases/${id}/screenshots`)}
>
</Button>
) : null
}
>
{uploadBatchItems.length === 0 ? (
<Empty description="暂无上传记录,请先拖拽截图上传" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<>
<Typography.Text type="secondary">
{uploadBatchItems.length}
{uploadBatchItems.filter((x) => x.status === 'success').length}
{uploadBatchItems.filter((x) => x.status === 'error').length}
{uploadBatchItems.filter((x) => x.status === 'uploading').length}
</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 10 }} size={8}>
{uploadBatchItems.map((item, index) => (
<Row key={item.localId} justify="space-between" align="middle">
<Col span={18}>
<Space size={8}>
<Typography.Text type="secondary">#{index + 1}</Typography.Text>
<div
style={{
width: 40,
height: 40,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
{item.previewUrl ? (
<img
src={item.previewUrl}
alt={item.fileName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : null}
</div>
<Typography.Text ellipsis style={{ maxWidth: 260 }}>
{item.fileName}
</Typography.Text>
<Typography.Text type="secondary">
{formatFileSize(item.fileSize)}
</Typography.Text>
</Space>
</Col>
<Col>
{item.status === 'uploading' && <Tag color="processing"></Tag>}
{item.status === 'success' && <Tag color="success"></Tag>}
{item.status === 'error' && <Tag color="error"></Tag>}
</Col>
</Row>
))}
</Space>
</>
)}
</Card>
</Card>
<Card title="处理进度">

View File

@@ -130,6 +130,23 @@ export async function fetchImageDetail(imageId: string): Promise<EvidenceImageDe
return request(`${BASE}/images/${imageId}`);
}
export async function deleteCaseImages(
caseId: string,
imageIds: string[],
): Promise<{ caseId: string; deleted: number; message: string }> {
if (!(await isBackendUp())) {
return {
caseId,
deleted: imageIds.length,
message: `Mock 模式:模拟删除 ${imageIds.length} 张截图`,
};
}
return request(`${BASE}/cases/${caseId}/images`, {
method: 'DELETE',
body: JSON.stringify({ image_ids: imageIds }),
});
}
export async function startCaseOcr(
caseId: string,
includeDone = false,