diff --git a/README.md b/README.md index c0b3014..a262930 100644 --- a/README.md +++ b/README.md @@ -72,19 +72,32 @@ bash infra/scripts/start-dev.sh ### 手动启动 ```bash -# 1. 启动基础设施 -cd infra/docker && docker compose up -d +# 1. 启动基础设施(PostgreSQL + Redis) +cd infra/docker +docker compose up -d +cd ../.. -# 2. 安装并启动后端 +# 2. 安装后端依赖 cd backend -pip install -e ".[dev]" -cp ../infra/env/.env.example .env # 按需编辑 +python -m venv .venv # 创建虚拟环境(推荐) +source .venv/bin/activate # macOS/Linux +# .venv\Scripts\activate # Windows +pip install -r requirements.txt # 安装依赖 + +# 3. 配置环境变量 +cp ../infra/env/.env.example .env # 复制模板,按需编辑 + +# 4. 初始化数据库 alembic revision --autogenerate -m "init" alembic upgrade head -python -m scripts.seed # 插入演示数据 -uvicorn app.main:app --reload --port 8000 -# 3. 安装并启动前端 +# 5. 插入演示数据 +python -m scripts.seed + +# 6. 启动后端服务 +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 7. 安装并启动前端(新开终端) cd frontend npm install npm run dev @@ -106,17 +119,23 @@ cd frontend && npm run dev ## 配置 AI 能力 -在 `backend/.env` 中配置: +系统使用两组独立的 OpenAI 兼容接口,在 `backend/.env` 中配置: -``` +```bash +# OCR — 截图识别与字段抽取(需要多模态/视觉能力) OCR_API_KEY=your_key OCR_API_URL=https://api.example.com/v1/chat/completions +OCR_MODEL=gpt-4o + +# LLM — 认定理由生成、问询建议等推理任务 LLM_API_KEY=your_key LLM_API_URL=https://api.example.com/v1/chat/completions -LLM_MODEL=model_name +LLM_MODEL=gpt-4o-mini ``` -支持 OpenAI 兼容格式的多模态 API。未配置时自动使用 mock 数据。 +- OCR 和 LLM 可以指向不同的供应商/模型(如 OCR 用视觉模型,LLM 用轻量文本模型) +- 如果只配置 LLM 而未配置 OCR,OCR 会自动降级使用 LLM 的配置 +- 两者均未配置时自动使用 mock 数据,不影响演示 ## 测试 diff --git a/backend/app/api/v1/images.py b/backend/app/api/v1/images.py index 58f0dab..122e769 100644 --- a/backend/app/api/v1/images.py +++ b/backend/app/api/v1/images.py @@ -1,5 +1,6 @@ from uuid import UUID import asyncio +from sqlalchemy import delete, select from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from fastapi.responses import FileResponse @@ -10,7 +11,13 @@ from app.core.database import get_db from app.models.evidence_image import EvidenceImage, SourceApp, PageType, OcrStatus from app.repositories.image_repo import ImageRepository from app.repositories.case_repo import CaseRepository -from app.schemas.image import ImageOut, ImageDetailOut, OcrFieldCorrection, CaseOcrStartIn +from app.schemas.image import ( + ImageOut, + ImageDetailOut, + OcrFieldCorrection, + CaseOcrStartIn, + CaseImagesDeleteIn, +) from app.utils.hash import sha256_file from app.utils.file_storage import save_upload @@ -172,6 +179,12 @@ async def start_case_ocr( await db.commit() else: images = await repo.list_for_ocr(case_id, include_done=include_done) + # Mark queued images as processing immediately, including when OCR is + # triggered from workspace page, so UI can show progress right away. + for img in images: + img.ocr_status = OcrStatus.processing + await db.flush() + await db.commit() from app.workers.ocr_tasks import process_images_ocr_batch_async @@ -190,3 +203,70 @@ async def start_case_ocr( "totalCandidates": len(images), "message": f"已提交 {submitted} 张截图的 OCR 任务", } + + +@router.delete("/cases/{case_id}/images") +async def delete_case_images( + case_id: UUID, + payload: CaseImagesDeleteIn, + db: AsyncSession = Depends(get_db), +): + case_repo = CaseRepository(db) + case = await case_repo.get(case_id) + if not case: + raise HTTPException(404, "案件不存在") + + if not payload.image_ids: + return {"caseId": str(case_id), "deleted": 0, "message": "未选择需要删除的截图"} + + repo = ImageRepository(db) + images = await repo.list_by_ids_in_case(case_id, payload.image_ids) + if not images: + return {"caseId": str(case_id), "deleted": 0, "message": "未找到可删除的截图"} + + from app.models.ocr_block import OcrBlock + from app.models.transaction import TransactionRecord + from app.models.assessment import FraudAssessment + + deleted = 0 + try: + for image in images: + # remove related OCR blocks and extracted transactions first + # assessments reference transaction_records.transaction_id, so they + # must be deleted before deleting transaction records. + await db.execute( + delete(FraudAssessment).where( + FraudAssessment.transaction_id.in_( + select(TransactionRecord.id).where( + TransactionRecord.evidence_image_id == image.id + ) + ) + ) + ) + await db.execute(delete(OcrBlock).where(OcrBlock.image_id == image.id)) + await db.execute(delete(TransactionRecord).where(TransactionRecord.evidence_image_id == image.id)) + await repo.delete(image) + deleted += 1 + + # best-effort remove local files + for rel in [image.file_path, image.thumb_path]: + if rel: + try: + p = settings.upload_path / rel + if p.exists(): + p.unlink() + except Exception: + pass + + case.image_count = await repo.count_by_case(case_id) + await db.flush() + await db.commit() + except Exception as e: + await db.rollback() + raise + + return { + "caseId": str(case_id), + "deleted": deleted, + "message": f"已删除 {deleted} 张截图", + } diff --git a/backend/app/schemas/image.py b/backend/app/schemas/image.py index 069069b..59eded5 100644 --- a/backend/app/schemas/image.py +++ b/backend/app/schemas/image.py @@ -38,3 +38,7 @@ class OcrFieldCorrection(CamelModel): class CaseOcrStartIn(CamelModel): include_done: bool = False image_ids: list[UUID] = [] + + +class CaseImagesDeleteIn(CamelModel): + image_ids: list[UUID] = [] diff --git a/frontend/src/pages/cases/CaseList.tsx b/frontend/src/pages/cases/CaseList.tsx index 97ae95d..0dd6899 100644 --- a/frontend/src/pages/cases/CaseList.tsx +++ b/frontend/src/pages/cases/CaseList.tsx @@ -36,6 +36,15 @@ const statusConfig: Record = { completed: { color: 'green', label: '已完成' }, }; +const splitDateTime = (raw: string): { date: string; time: string } => { + if (!raw) return { date: '-', time: '-' }; + const normalized = raw.trim().replace(' ', 'T'); + if (!normalized.includes('T')) return { date: normalized, time: '-' }; + const [datePart, timePartRaw = ''] = normalized.split('T'); + const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-'; + return { date: datePart || '-', time: cleanedTime }; +}; + const CaseList: React.FC = () => { const navigate = useNavigate(); const qc = useQueryClient(); @@ -71,18 +80,19 @@ const CaseList: React.FC = () => { { title: '案件编号', dataIndex: 'caseNo', - width: 180, + width: 120, + ellipsis: true, render: (text, record) => ( navigate(`/cases/${record.id}/workspace`)}>{text} ), }, - { title: '案件名称', dataIndex: 'title', ellipsis: true }, - { title: '受害人', dataIndex: 'victimName', width: 100 }, - { title: '承办人', dataIndex: 'handler', width: 100 }, + { title: '案件名称', dataIndex: 'title', width: 140, ellipsis: true }, + { title: '受害人', dataIndex: 'victimName', width: 80 }, + { title: '承办人', dataIndex: 'handler', width: 80 }, { title: '状态', dataIndex: 'status', - width: 100, + width: 60, render: (s: CaseStatus) => ( {statusConfig[s].label} ), @@ -96,7 +106,7 @@ const CaseList: React.FC = () => { { title: '识别金额(元)', dataIndex: 'totalAmount', - width: 140, + width: 120, align: 'right', render: (v: number) => v > 0 ? ( @@ -110,11 +120,23 @@ const CaseList: React.FC = () => { { title: '更新时间', dataIndex: 'updatedAt', - width: 170, + width: 110, + render: (raw: string) => { + const { date, time } = splitDateTime(raw); + return ( +
+ {date} +
+ + {time} + +
+ ); + }, }, { title: '操作', - width: 160, + width: 108, render: (_, record) => ( + ) : null + } + > + {uploadBatchItems.length === 0 ? ( + + ) : ( + <> + + 本次上传:共 {uploadBatchItems.length} 张, + 成功 {uploadBatchItems.filter((x) => x.status === 'success').length} 张, + 失败 {uploadBatchItems.filter((x) => x.status === 'error').length} 张, + 上传中 {uploadBatchItems.filter((x) => x.status === 'uploading').length} 张 + + + {uploadBatchItems.map((item, index) => ( + + + + #{index + 1} +
+ {item.previewUrl ? ( + {item.fileName} + ) : null} +
+ + {item.fileName} + + + {formatFileSize(item.fileSize)} + +
+ + + {item.status === 'uploading' && 上传中} + {item.status === 'success' && 上传成功} + {item.status === 'error' && 上传失败} + +
+ ))} +
+ + )} + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8493ded..9bd7398 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -130,6 +130,23 @@ export async function fetchImageDetail(imageId: string): Promise { + if (!(await isBackendUp())) { + return { + caseId, + deleted: imageIds.length, + message: `Mock 模式:模拟删除 ${imageIds.length} 张截图`, + }; + } + return request(`${BASE}/cases/${caseId}/images`, { + method: 'DELETE', + body: JSON.stringify({ image_ids: imageIds }), + }); +} + export async function startCaseOcr( caseId: string, includeDone = false,