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 ```bash
# 1. 启动基础设施 # 1. 启动基础设施PostgreSQL + Redis
cd infra/docker && docker compose up -d cd infra/docker
docker compose up -d
cd ../..
# 2. 安装并启动后端 # 2. 安装后端依赖
cd backend cd backend
pip install -e ".[dev]" python -m venv .venv # 创建虚拟环境(推荐)
cp ../infra/env/.env.example .env # 按需编辑 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 revision --autogenerate -m "init"
alembic upgrade head 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 cd frontend
npm install npm install
npm run dev npm run dev
@@ -106,17 +119,23 @@ cd frontend && npm run dev
## 配置 AI 能力 ## 配置 AI 能力
`backend/.env` 中配置: 系统使用两组独立的 OpenAI 兼容接口,`backend/.env` 中配置:
``` ```bash
# OCR — 截图识别与字段抽取(需要多模态/视觉能力)
OCR_API_KEY=your_key OCR_API_KEY=your_key
OCR_API_URL=https://api.example.com/v1/chat/completions OCR_API_URL=https://api.example.com/v1/chat/completions
OCR_MODEL=gpt-4o
# LLM — 认定理由生成、问询建议等推理任务
LLM_API_KEY=your_key LLM_API_KEY=your_key
LLM_API_URL=https://api.example.com/v1/chat/completions 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 from uuid import UUID
import asyncio import asyncio
from sqlalchemy import delete, select
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse 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.models.evidence_image import EvidenceImage, SourceApp, PageType, OcrStatus
from app.repositories.image_repo import ImageRepository from app.repositories.image_repo import ImageRepository
from app.repositories.case_repo import CaseRepository 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.hash import sha256_file
from app.utils.file_storage import save_upload from app.utils.file_storage import save_upload
@@ -172,6 +179,12 @@ async def start_case_ocr(
await db.commit() await db.commit()
else: else:
images = await repo.list_for_ocr(case_id, include_done=include_done) 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 from app.workers.ocr_tasks import process_images_ocr_batch_async
@@ -190,3 +203,70 @@ async def start_case_ocr(
"totalCandidates": len(images), "totalCandidates": len(images),
"message": f"已提交 {submitted} 张截图的 OCR 任务", "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): class CaseOcrStartIn(CamelModel):
include_done: bool = False include_done: bool = False
image_ids: list[UUID] = [] 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: '已完成' }, 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 CaseList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -71,18 +80,19 @@ const CaseList: React.FC = () => {
{ {
title: '案件编号', title: '案件编号',
dataIndex: 'caseNo', dataIndex: 'caseNo',
width: 180, width: 120,
ellipsis: true,
render: (text, record) => ( render: (text, record) => (
<a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a> <a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a>
), ),
}, },
{ title: '案件名称', dataIndex: 'title', ellipsis: true }, { title: '案件名称', dataIndex: 'title', width: 140, ellipsis: true },
{ title: '受害人', dataIndex: 'victimName', width: 100 }, { title: '受害人', dataIndex: 'victimName', width: 80 },
{ title: '承办人', dataIndex: 'handler', width: 100 }, { title: '承办人', dataIndex: 'handler', width: 80 },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 60,
render: (s: CaseStatus) => ( render: (s: CaseStatus) => (
<Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag> <Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag>
), ),
@@ -96,7 +106,7 @@ const CaseList: React.FC = () => {
{ {
title: '识别金额(元)', title: '识别金额(元)',
dataIndex: 'totalAmount', dataIndex: 'totalAmount',
width: 140, width: 120,
align: 'right', align: 'right',
render: (v: number) => render: (v: number) =>
v > 0 ? ( v > 0 ? (
@@ -110,11 +120,23 @@ const CaseList: React.FC = () => {
{ {
title: '更新时间', title: '更新时间',
dataIndex: 'updatedAt', 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: '操作', title: '操作',
width: 160, width: 108,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Button <Button
@@ -207,6 +229,7 @@ const CaseList: React.FC = () => {
columns={columns} columns={columns}
dataSource={cases} dataSource={cases}
loading={isLoading} loading={isLoading}
scroll={{ x: 'max-content' }}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `${t}` }} pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `${t}` }}
/> />
</Card> </Card>

View File

@@ -67,6 +67,15 @@ const saveContentSelection = (caseId: string, sel: Record<ContentKeys, boolean>)
} catch { /* ignore */ } } 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 Reports: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -156,7 +165,19 @@ const Reports: React.FC = () => {
{ {
title: '生成时间', title: '生成时间',
dataIndex: 'createdAt', 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: '操作', title: '操作',

View File

@@ -11,6 +11,7 @@ import {
Button, Button,
Drawer, Drawer,
Input, Input,
InputNumber,
Select, Select,
Descriptions, Descriptions,
Row, Row,
@@ -70,9 +71,68 @@ const aiSuggestionLabel: Record<ReviewAction, string> = {
needs_info: '需补充调查', 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.assessedAmount <= 0 || assessment.confidenceLevel === 'low') return 'rejected';
if (assessment.confidenceLevel === 'high') return 'confirmed'; if (assessment.confidenceLevel === 'high') return 'confirmed';
if (assessment.confidenceLevel === 'medium') {
if (hasBrushingSignals(assessment) || hasNearbySimilarPattern(assessment, allAssessments)) {
return 'confirmed';
}
}
return 'needs_info'; return 'needs_info';
}; };
@@ -91,6 +151,7 @@ const Review: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const [filterLevel, setFilterLevel] = useState<string>('all'); const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null); const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null);
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed'); const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
const [reviewNote, setReviewNote] = useState(''); const [reviewNote, setReviewNote] = useState('');
@@ -240,7 +301,7 @@ const Review: React.FC = () => {
title: '状态', title: '状态',
width: 74, width: 74,
render: (_, r) => { render: (_, r) => {
const aiAction = getAiSuggestedAction(r); const aiAction = getAiSuggestedAction(r, allAssessments);
const isPending = r.reviewStatus === 'pending'; const isPending = r.reviewStatus === 'pending';
const status = isPending ? aiAction : r.reviewStatus; const status = isPending ? aiAction : r.reviewStatus;
@@ -378,7 +439,8 @@ const Review: React.FC = () => {
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => { onClick={() => {
setReviewModal(r); setReviewModal(r);
setReviewAction(getAiSuggestedAction(r)); setEditableTx({ ...r.transaction });
setReviewAction(getAiSuggestedAction(r, allAssessments));
setReviewNote(''); setReviewNote('');
}} }}
> >
@@ -564,10 +626,13 @@ const Review: React.FC = () => {
<Drawer <Drawer
title="查看 / 复核" title="查看 / 复核"
open={!!reviewModal} open={!!reviewModal}
onClose={() => setReviewModal(null)} onClose={() => {
setReviewModal(null);
setEditableTx(null);
}}
width={720} width={720}
> >
{reviewModal && ( {reviewModal && editableTx && (
<> <>
<Row gutter={16} align="top"> <Row gutter={16} align="top">
<Col span={10}> <Col span={10}>
@@ -601,18 +666,103 @@ const Review: React.FC = () => {
<Col span={14}> <Col span={14}>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="交易时间"> <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>
<Descriptions.Item label="金额"> <Descriptions.Item label="金额">
<Typography.Text strong style={{ color: '#cf1322' }}> <InputNumber
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} style={{ width: '100%' }}
</Typography.Text> value={editableTx.amount}
onChange={(val) =>
setEditableTx((prev) => (prev ? { ...prev, amount: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="对方"> <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>
<Descriptions.Item label="来源APP"> <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>
<Descriptions.Item label="置信等级"> <Descriptions.Item label="置信等级">
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}> <Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
@@ -620,7 +770,7 @@ const Review: React.FC = () => {
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="AI建议"> <Descriptions.Item label="AI建议">
<Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}</Tag> <Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal, allAssessments)]}</Tag>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
@@ -644,7 +794,7 @@ const Review: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal)]} AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal, allAssessments)]}
</Typography.Text> </Typography.Text>
<Select <Select
value={reviewAction} value={reviewAction}
@@ -681,7 +831,12 @@ const Review: React.FC = () => {
</Row> </Row>
<Divider /> <Divider />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setReviewModal(null)}> <Button
onClick={() => {
setReviewModal(null);
setEditableTx(null);
}}
>
</Button> </Button>
{reviewModal.reviewStatus === 'pending' && ( {reviewModal.reviewStatus === 'pending' && (

View File

@@ -31,9 +31,10 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
ZoomInOutlined, ZoomInOutlined,
PlayCircleOutlined, PlayCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types'; 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 }> = { const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -182,11 +183,49 @@ const Screenshots: React.FC = () => {
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({}); const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({});
const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]); const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]);
const [lastProcessingCount, setLastProcessingCount] = useState(0);
const { data: allImages = [] } = useQuery({ const { data: allImages = [] } = useQuery({
queryKey: ['images', id], queryKey: ['images', id],
queryFn: () => fetchImages(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({ const triggerOcrMutation = useMutation({
mutationFn: (targetIds: string[]) => mutationFn: (targetIds: string[]) =>
@@ -276,6 +315,15 @@ const Screenshots: React.FC = () => {
if (backendStatus === 'done' && tracking.sawProcessing) return 'done'; if (backendStatus === 'done' && tracking.sawProcessing) return 'done';
return 'processing'; 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(() => { React.useEffect(() => {
if (Object.keys(rerunTracking).length === 0) return; if (Object.keys(rerunTracking).length === 0) return;
const statusById = new Map(allImages.map((img) => [img.id, img.ocrStatus] as const)); 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} disabled={selectedIds.length === 0}
onClick={() => triggerOcrMutation.mutate(selectedIds)} 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>
<Button onClick={selectAllFiltered}></Button> <Button onClick={selectAllFiltered}></Button>
<Button onClick={clearSelection} disabled={selectedIds.length === 0}></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 { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@@ -17,6 +17,9 @@ import {
Row, Row,
Col, Col,
Statistic, Statistic,
Input,
InputNumber,
Dropdown,
} from 'antd'; } from 'antd';
import { import {
SwapOutlined, SwapOutlined,
@@ -28,7 +31,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types'; 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 }> = { const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -38,45 +41,84 @@ const appTag: Record<SourceApp, { label: string; color: string }> = {
other: { label: '其他', color: 'default' }, 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 Transactions: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all'); const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null); 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({ const { data: txData } = useQuery({
queryKey: ['transactions', id], queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id), queryFn: () => fetchTransactions(id),
}); });
const allTransactions = txData?.items ?? []; 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 = const data =
filterDuplicate === 'all' filterDuplicate === 'all'
? allTransactions ? allTransactions
: filterDuplicate === 'unique' : filterDuplicate === 'unique'
? allTransactions.filter((t) => !t.isDuplicate) ? allTransactions.filter((t) => getEffectiveMark(t) !== 'duplicate')
: allTransactions.filter((t) => t.isDuplicate); : allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate');
const totalOut = allTransactions 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); .reduce((s, t) => s + t.amount, 0);
const totalIn = allTransactions 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); .reduce((s, t) => s + t.amount, 0);
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length; const duplicateCount = allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate').length;
const transitCount = allTransactions.filter((t) => t.isTransit).length; const transitCount = allTransactions.filter((t) => getEffectiveMark(t) === 'transit').length;
const columns: ColumnsType<TransactionRecord> = [ const columns: ColumnsType<TransactionRecord> = [
{ {
title: '交易时间', title: '交易时间',
dataIndex: 'tradeTime', 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), sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
defaultSortOrder: 'ascend', defaultSortOrder: 'ascend',
}, },
{ {
title: '来源', title: '来源',
dataIndex: 'sourceApp', dataIndex: 'sourceApp',
width: 100, width: 80,
render: (app: SourceApp) => ( render: (app: SourceApp) => (
<Tag color={appTag[app].color}>{appTag[app].label}</Tag> <Tag color={appTag[app].color}>{appTag[app].label}</Tag>
), ),
@@ -110,6 +152,7 @@ const Transactions: React.FC = () => {
{ {
title: '对方', title: '对方',
dataIndex: 'counterpartyName', dataIndex: 'counterpartyName',
width: 120,
ellipsis: true, ellipsis: true,
}, },
{ {
@@ -120,23 +163,74 @@ const Transactions: React.FC = () => {
}, },
{ {
title: '标记', title: '标记',
width: 130, width: 73,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> (() => {
{r.isDuplicate && ( const mark = getEffectiveMark(r);
<Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并"> const styleByMark: Record<'duplicate' | 'transit' | 'valid', { bg: string; border: string; color: string; label: string }> = {
<Tag color="red"></Tag> duplicate: { bg: '#fff2e8', border: '#ffbb96', color: '#cf1322', label: '重复' },
</Tooltip> transit: { bg: '#fff7e6', border: '#ffd591', color: '#d46b08', label: '中转' },
)} valid: { bg: '#f6ffed', border: '#b7eb8f', color: '#389e0d', label: '有效' },
{r.isTransit && ( };
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额"> const cfg = styleByMark[mark];
<Tag color="orange"></Tag> const options: Array<'duplicate' | 'transit' | 'valid'> = ['duplicate', 'transit', 'valid'];
</Tooltip> return (
)} <Space.Compact size="small" style={{ width: '100%' }}>
{!r.isDuplicate && !r.isTransit && ( <Tooltip
<Tag color="green"></Tag> title={
)} mark === 'duplicate'
</Space> ? '该笔与其他记录订单号一致,判定为同一笔展示记录并已归并'
: 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>
<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" rowKey="id"
columns={columns} columns={columns}
dataSource={data} dataSource={data}
scroll={{ x: 'max-content' }}
pagination={false} pagination={false}
rowClassName={(r) => rowClassName={(r) =>
r.isDuplicate getEffectiveMark(r) === 'duplicate'
? 'row-duplicate' ? 'row-duplicate'
: r.isTransit : getEffectiveMark(r) === 'transit'
? 'row-transit' ? 'row-transit'
: '' : ''
} }
@@ -267,65 +362,156 @@ const Transactions: React.FC = () => {
<Drawer <Drawer
title="交易详情" title="交易详情"
placement="right" placement="right"
width={480} width={780}
open={!!detail} open={!!detail}
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
> >
{detail && ( {detail && editableDetail && (
<Descriptions column={1} bordered size="small"> <Row gutter={16} align="top">
<Descriptions.Item label="交易时间"> <Col span={10}>
{detail.tradeTime} <Card size="small" loading={detailImageFetching}>
</Descriptions.Item> <Typography.Text strong></Typography.Text>
<Descriptions.Item label="来源APP"> <div
<Tag color={appTag[detail.sourceApp].color}> style={{
{appTag[detail.sourceApp].label} marginTop: 10,
</Tag> height: 430,
</Descriptions.Item> background: '#fafafa',
<Descriptions.Item label="金额"> border: '1px dashed #d9d9d9',
<Typography.Text borderRadius: 6,
strong display: 'flex',
style={{ alignItems: 'center',
color: detail.direction === 'out' ? '#cf1322' : '#389e0d', justifyContent: 'center',
}} overflow: 'hidden',
> }}
{detail.direction === 'out' ? '-' : '+'}¥ >
{detail.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} {detailImage?.url ? (
</Typography.Text> <img
</Descriptions.Item> src={detailImage.url}
<Descriptions.Item label="对方"> alt="来源截图"
{detail.counterpartyName} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
</Descriptions.Item> />
<Descriptions.Item label="对方账号"> ) : (
{detail.counterpartyAccount || '-'} <Typography.Text type="secondary"></Typography.Text>
</Descriptions.Item> )}
<Descriptions.Item label="本方账户尾号"> </div>
{detail.selfAccountTailNo || '-'} </Card>
</Descriptions.Item> </Col>
<Descriptions.Item label="订单号"> <Col span={14}>
{detail.orderNo} <Descriptions column={1} bordered size="small">
</Descriptions.Item> <Descriptions.Item label="交易时间">
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item> <Input
<Descriptions.Item label="置信度"> value={editableDetail.tradeTime ?? ''}
{(detail.confidence * 100).toFixed(0)}% onChange={(e) =>
</Descriptions.Item> setEditableDetail((prev) => (prev ? { ...prev, tradeTime: e.target.value } : prev))
<Descriptions.Item label="证据截图"> }
<Button type="link" size="small" icon={<EyeOutlined />}> />
({detail.evidenceImageId}) </Descriptions.Item>
</Button> <Descriptions.Item label="来源APP">
</Descriptions.Item> <Select
<Descriptions.Item label="归并簇"> style={{ width: '100%' }}
{detail.clusterId || '独立交易'} value={editableDetail.sourceApp}
</Descriptions.Item> onChange={(val) =>
<Descriptions.Item label="标记"> setEditableDetail((prev) => (prev ? { ...prev, sourceApp: val } : prev))
<Space> }
{detail.isDuplicate && <Tag color="red"></Tag>} options={[
{detail.isTransit && <Tag color="orange"></Tag>} { label: '微信', value: 'wechat' },
{!detail.isDuplicate && !detail.isTransit && ( { label: '支付宝', value: 'alipay' },
<Tag color="green"></Tag> { label: '银行', value: 'bank' },
)} { label: '数字钱包', value: 'digital_wallet' },
</Space> { label: '其他', value: 'other' },
</Descriptions.Item> ]}
</Descriptions> />
</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="对方">
<Input
value={editableDetail.counterpartyName ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyName: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="对方账号">
<Input
value={editableDetail.counterpartyAccount ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyAccount: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
<Input
value={editableDetail.selfAccountTailNo ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, selfAccountTailNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="订单号">
<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="置信度">
<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="归并簇">
<Input value={editableDetail.clusterId || ''} readOnly />
</Descriptions.Item>
<Descriptions.Item label="标记">
<Space>
{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> </Drawer>
</div> </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 { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -16,6 +16,7 @@ import {
Descriptions, Descriptions,
Progress, Progress,
Alert, Alert,
Empty,
} from 'antd'; } from 'antd';
import { import {
CloudUploadOutlined, CloudUploadOutlined,
@@ -34,12 +35,39 @@ import type { EvidenceImage } from '../../types';
const { Dragger } = Upload; 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 Workspace: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { message } = App.useApp(); const { message } = App.useApp();
const [uploadingCount, setUploadingCount] = useState(0); 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: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) }); const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
@@ -206,29 +234,80 @@ const Workspace: React.FC = () => {
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
beforeUpload={(file) => { beforeUpload={(file) => {
setUploadingCount((c) => c + 1); if (!batchActiveRef.current) {
message.open({ batchActiveRef.current = true;
key: 'img-upload', batchCounterRef.current = { success: 0, failed: 0 };
type: 'loading', setUploadBatchItems((prev) => {
content: `正在上传截图(队列中 ${uploadingCount + 1} 张)...`, prev.forEach((item) => {
duration: 0, 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: `正在上传截图(队列中 ${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]) uploadImages(id, [file as File])
.then(() => { .then((uploaded) => {
message.success('截图上传成功'); const uploadedImage = uploaded?.[0];
setUploadBatchItems((prev) =>
prev.map((item) =>
item.localId === localId
? {
...item,
status: 'success',
uploadedImageId: uploadedImage?.id,
}
: item,
),
);
batchCounterRef.current.success += 1;
}) })
.then(() => { .then(() => {
queryClient.invalidateQueries({ queryKey: ['images', id] }); queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] }); queryClient.invalidateQueries({ queryKey: ['case', id] });
}) })
.catch(() => { .catch(() => {
message.error('上传失败'); setUploadBatchItems((prev) =>
prev.map((item) =>
item.localId === localId
? { ...item, status: 'error' }
: item,
),
);
batchCounterRef.current.failed += 1;
}) })
.finally(() => { .finally(() => {
setUploadingCount((c) => { setUploadingCount((c) => {
const next = Math.max(0, c - 1); const next = Math.max(0, c - 1);
if (next === 0) { if (next === 0) {
batchActiveRef.current = false;
message.destroy('img-upload'); message.destroy('img-upload');
const summary = {
success: batchCounterRef.current.success,
failed: batchCounterRef.current.failed,
};
message.success(`本次上传完成:成功 ${summary.success} 张,失败 ${summary.failed}`);
} else { } else {
message.open({ message.open({
key: 'img-upload', key: 'img-upload',
@@ -259,6 +338,75 @@ const Workspace: React.FC = () => {
{uploadingCount} ... {uploadingCount} ...
</Typography.Text> </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>
<Card title="处理进度"> <Card title="处理进度">

View File

@@ -130,6 +130,23 @@ export async function fetchImageDetail(imageId: string): Promise<EvidenceImageDe
return request(`${BASE}/images/${imageId}`); 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( export async function startCaseOcr(
caseId: string, caseId: string,
includeDone = false, includeDone = false,