update: fix-02
This commit is contained in:
43
README.md
43
README.md
@@ -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 而未配置 OCR,OCR 会自动降级使用 LLM 的配置
|
||||||
|
- 两者均未配置时自动使用 mock 数据,不影响演示
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
|
|||||||
@@ -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} 张截图",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '操作',
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="处理进度">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user