From e526d25fd02e45970677315e6f72dab0d1740e63 Mon Sep 17 00:00:00 2001 From: CJohn Date: Sun, 26 Jul 2026 12:19:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ocr):=20=E5=AE=8C=E5=96=84=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E5=AF=BC=E5=85=A5=E7=9A=84=E5=AE=89=E5=85=A8=E9=99=90?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Pillow 完整解码前检查图片像素数,并将 DecompressionBombError 转为明确的参数错误,避免压缩大图在校验前占用过多内存 - 为 OCR 线程任务设置独立的 AnyIO CapacityLimiter(2),最多同时执行 两次图片解码与 Tesseract 识别,其余请求排队等待 - 前端调用 ocr-status 检查 Tesseract 是否可用;不可用时禁用截图导入 入口,并按 Windows、macOS 和 Linux 显示对应安装说明 - 关闭弹窗或重新选择图片时取消旧请求,并通过请求代次校验忽略迟到 的异步结果,防止旧候选回写到新弹窗 - 禁用已存在于自选列表中的候选项,批量添加接口返回实际净新增数量, 成功提示使用后端返回值 - 补充图片类型与大小校验、解压炸弹、OCR 状态、并发限制以及批量添加 数量等测试 --- backend/app/api/watchlist.py | 15 +- .../app/services/watchlist_ocr/pipeline.py | 6 +- .../app/services/watchlist_ocr/provider.py | 9 +- backend/tests/test_watchlist_ocr.py | 186 +++++++++++- .../src/components/WatchlistImportDialog.tsx | 264 +++++++++++------- frontend/src/lib/api.ts | 3 +- frontend/src/lib/ocrInstallHint.ts | 41 +++ frontend/src/pages/Watchlist.tsx | 36 ++- 8 files changed, 440 insertions(+), 120 deletions(-) create mode 100644 frontend/src/lib/ocrInstallHint.ts diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 004c3ef..13db6d8 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -6,6 +6,7 @@ import math import time from datetime import date +import anyio import polars as pl from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile from pydantic import BaseModel @@ -27,6 +28,8 @@ _IMPORT_IMAGE_TYPES = { "image/bmp", "image/gif", } +# OCR 独立并发上限:避免多张大图同时解码 + 多 Tesseract 子进程 +_OCR_LIMITER = anyio.CapacityLimiter(2) class AddRequest(BaseModel): @@ -66,9 +69,14 @@ def add_one(req: AddRequest, request: Request): @router.post("/batch") def add_batch(req: BatchAddRequest, request: Request): + existing = {r["symbol"] for r in watchlist.list_symbols()} + added = 0 for sym in req.symbols: + if sym not in existing: + added += 1 + existing.add(sym) watchlist.add(sym, req.note) - return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)} + return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added} @router.get("/ocr-status") @@ -81,8 +89,6 @@ def ocr_status(): @router.post("/import-image") async def import_from_image(request: Request, file: UploadFile = File(...)): """从自选截图识别股票代码,返回候选列表(不自动写入自选)。""" - import anyio - content_type = (file.content_type or "").split(";")[0].strip().lower() filename = (file.filename or "").lower() # 严格白名单:不接受任意 image/*(如 image/svg+xml) @@ -100,9 +106,10 @@ async def import_from_image(request: Request, file: UploadFile = File(...)): existing = {r["symbol"] for r in watchlist.list_symbols()} data_dir = request.app.state.repo.store.data_dir try: - # OCR 为同步 CPU/子进程;丢进线程池,避免卡住事件循环(行情 SSE 等) + # OCR 为同步 CPU/子进程;独立 limiter 限制并发,避免卡住事件循环(行情 SSE 等) result = await anyio.to_thread.run_sync( lambda: import_watchlist_image(data, data_dir, existing_symbols=existing), + limiter=_OCR_LIMITER, ) except ValueError as e: raise HTTPException(400, str(e)) from e diff --git a/backend/app/services/watchlist_ocr/pipeline.py b/backend/app/services/watchlist_ocr/pipeline.py index f47f0c5..56e859c 100644 --- a/backend/app/services/watchlist_ocr/pipeline.py +++ b/backend/app/services/watchlist_ocr/pipeline.py @@ -130,8 +130,10 @@ def import_watchlist_image( ocr = provider or get_ocr_provider() if not ocr.available(): raise RuntimeError( - f"OCR 引擎「{ocr.name}」不可用。请安装 Tesseract(macOS: brew install tesseract;" - "Docker 镜像已内置)。" + f"OCR 引擎「{ocr.name}」不可用。请安装 Tesseract:" + "macOS 执行 brew install tesseract;" + "Windows 可安装 UB Mannheim 发行版或执行 choco install tesseract;" + "Linux/Docker 安装 tesseract-ocr(官方镜像已内置)。" ) text = ocr.extract_text(image_bytes) diff --git a/backend/app/services/watchlist_ocr/provider.py b/backend/app/services/watchlist_ocr/provider.py index f7a6f3e..8fbcfb5 100644 --- a/backend/app/services/watchlist_ocr/provider.py +++ b/backend/app/services/watchlist_ocr/provider.py @@ -36,14 +36,17 @@ def preprocess_for_ocr(image_bytes: bytes) -> Image.Image: """暗色券商截图预处理:像素上限、降采样、灰度、反相、增强对比。 Raises: - ValueError: 图片分辨率过高(像素数超限)。 + ValueError: 图片分辨率过高(像素数超限)或解压炸弹。 """ img = Image.open(BytesIO(image_bytes)) - img.load() # 强制解码,便于尽早失败 / 量尺寸 - + # 多数格式可读头得到尺寸,在完整解码前拒绝超限图,避免 OOM pixels = img.width * img.height if pixels > _MAX_PIXELS: raise ValueError("图片分辨率过高,请裁剪后重试") + try: + img.load() + except Image.DecompressionBombError as e: + raise ValueError("图片分辨率过高,请裁剪后重试") from e # 大图降采样:既减内存又提速 OCR(只放大不缩小的旧逻辑已去掉) if max(img.size) > _MAX_EDGE: diff --git a/backend/tests/test_watchlist_ocr.py b/backend/tests/test_watchlist_ocr.py index dd266b4..843043b 100644 --- a/backend/tests/test_watchlist_ocr.py +++ b/backend/tests/test_watchlist_ocr.py @@ -1,13 +1,22 @@ -"""自选截图 OCR:代码抽取与 instruments 匹配(不依赖本机 tesseract)。""" +"""自选截图 OCR:代码抽取、预处理、API 门禁与并发限制(不依赖本机 tesseract)。""" from __future__ import annotations +import threading +import time from io import BytesIO from pathlib import Path +from unittest.mock import AsyncMock, MagicMock +import anyio import polars as pl import pytest +from fastapi import HTTPException from PIL import Image +from app.api import watchlist as watchlist_api +from app.api.watchlist import BatchAddRequest, add_batch, import_from_image, ocr_status +from app.config import settings +from app.services import watchlist from app.services.watchlist_ocr.pipeline import ( extract_codes, import_watchlist_image, @@ -19,11 +28,12 @@ from app.services.watchlist_ocr.provider import OcrProvider, preprocess_for_ocr class _FakeOcr(OcrProvider): name = "fake" - def __init__(self, text: str) -> None: + def __init__(self, text: str, *, available: bool = True) -> None: self._text = text + self._available = available def available(self) -> bool: - return True + return self._available def extract_text(self, image_bytes: bytes) -> str: return self._text @@ -35,6 +45,26 @@ def _png_bytes(width: int, height: int) -> bytes: return buf.getvalue() +def _mock_upload( + *, + content: bytes = b"img", + content_type: str = "image/png", + filename: str = "shot.png", +) -> MagicMock: + file = MagicMock() + file.content_type = content_type + file.filename = filename + file.read = AsyncMock(return_value=content) + return file + + +def _mock_request(data_dir: Path) -> MagicMock: + request = MagicMock() + request.app.state.repo.store.data_dir = data_dir + request.app.state.repo.get_name_map = lambda _symbols: {} + return request + + def test_extract_codes_order_and_dedupe(): text = "563230 融\n515880\n价格 1.340\n563230 重复\nXAUUSD\n601636" assert extract_codes(text) == ["563230", "515880", "601636"] @@ -84,13 +114,161 @@ def test_import_watchlist_image_with_fake_ocr(tmp_path: Path): assert result["unmatched_count"] == 0 -def test_preprocess_rejects_excessive_pixels(): +def test_import_unavailable_mentions_windows(): + with pytest.raises(RuntimeError, match="Windows|choco|UB Mannheim"): + import_watchlist_image( + b"x", + Path("/tmp"), + provider=_FakeOcr("", available=False), + ) + + +def test_preprocess_rejects_excessive_pixels_before_load(monkeypatch): + """超限图应在完整解码前拒绝,避免 load() 造成 OOM。""" + loaded = {"called": False} + real_open = Image.open + + def open_tracking(fp, *args, **kwargs): + img = real_open(fp, *args, **kwargs) + original_load = img.load + + def load_tracking(*a, **k): + loaded["called"] = True + return original_load(*a, **k) + + img.load = load_tracking # type: ignore[method-assign] + return img + + monkeypatch.setattr(Image, "open", open_tracking) # 4000×8000 = 32M 像素 > 12M 上限 with pytest.raises(ValueError, match="分辨率过高"): preprocess_for_ocr(_png_bytes(4000, 8000)) + assert loaded["called"] is False + + +def test_preprocess_maps_decompression_bomb_to_value_error(monkeypatch): + class _BombImg: + width = 100 + height = 100 + + def load(self): + raise Image.DecompressionBombError("bomb") + + monkeypatch.setattr(Image, "open", lambda *_a, **_k: _BombImg()) + with pytest.raises(ValueError, match="分辨率过高"): + preprocess_for_ocr(b"fake") def test_preprocess_downsamples_large_edge(): # 2500×1000 = 2.5M 像素未超限,但长边 > 2000,应降采样 out = preprocess_for_ocr(_png_bytes(2500, 1000)) assert max(out.size) <= 2000 + + +def test_ocr_status_reflects_provider(monkeypatch): + monkeypatch.setattr( + watchlist_api, + "get_ocr_provider", + lambda: _FakeOcr("", available=True), + ) + assert ocr_status() == {"provider": "fake", "available": True} + + monkeypatch.setattr( + watchlist_api, + "get_ocr_provider", + lambda: _FakeOcr("", available=False), + ) + assert ocr_status() == {"provider": "fake", "available": False} + + +@pytest.mark.asyncio +async def test_import_image_rejects_bad_mime(tmp_path: Path): + request = _mock_request(tmp_path) + file = _mock_upload(content_type="image/svg+xml", filename="x.svg") + with pytest.raises(HTTPException) as ei: + await import_from_image(request, file) + assert ei.value.status_code == 400 + assert "仅支持" in str(ei.value.detail) + + +@pytest.mark.asyncio +async def test_import_image_rejects_oversized_bytes(tmp_path: Path): + request = _mock_request(tmp_path) + huge = b"x" * (12 * 1024 * 1024 + 1) + file = _mock_upload(content=huge) + with pytest.raises(HTTPException) as ei: + await import_from_image(request, file) + assert ei.value.status_code == 400 + assert "过大" in str(ei.value.detail) + + +@pytest.mark.asyncio +async def test_import_image_maps_value_error_to_400(tmp_path: Path, monkeypatch): + request = _mock_request(tmp_path) + monkeypatch.setattr(watchlist, "list_symbols", lambda: []) + + def boom(*_a, **_k): + raise ValueError("图片分辨率过高,请裁剪后重试") + + monkeypatch.setattr(watchlist_api, "import_watchlist_image", boom) + file = _mock_upload(content=_png_bytes(64, 64)) + with pytest.raises(HTTPException) as ei: + await import_from_image(request, file) + assert ei.value.status_code == 400 + assert "分辨率过高" in str(ei.value.detail) + + +@pytest.mark.asyncio +async def test_ocr_limiter_caps_concurrency(tmp_path: Path, monkeypatch): + """第三路 OCR 应排队,同时进入同步 OCR 的不超过 2。""" + current = 0 + max_seen = 0 + lock = threading.Lock() + + def slow_import(*_a, **_k): + nonlocal current, max_seen + with lock: + current += 1 + max_seen = max(max_seen, current) + time.sleep(0.15) + with lock: + current -= 1 + return { + "provider": "fake", + "candidates": [], + "codes": [], + "matched_count": 0, + "unmatched_count": 0, + "raw_text": "", + } + + monkeypatch.setattr(watchlist_api, "import_watchlist_image", slow_import) + monkeypatch.setattr(watchlist, "list_symbols", lambda: []) + # 使用独立 limiter,避免与其它用例共享状态 + monkeypatch.setattr(watchlist_api, "_OCR_LIMITER", anyio.CapacityLimiter(2)) + + request = _mock_request(tmp_path) + + async def one(): + file = _mock_upload(content=_png_bytes(64, 64)) + return await import_from_image(request, file) + + async with anyio.create_task_group() as tg: + for _ in range(3): + tg.start_soon(one) + + assert max_seen <= 2 + assert max_seen >= 1 + + +def test_add_batch_reports_net_new(monkeypatch, tmp_path: Path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + watchlist.add("600036.SH") + request = _mock_request(tmp_path) + result = add_batch( + BatchAddRequest(symbols=["600036.SH", "515880.SH", "000001.SZ"]), + request, + ) + assert result["added"] == 2 + symbols = {r["symbol"] for r in result["symbols"]} + assert symbols == {"600036.SH", "515880.SH", "000001.SZ"} diff --git a/frontend/src/components/WatchlistImportDialog.tsx b/frontend/src/components/WatchlistImportDialog.tsx index 85ce41c..cc49b90 100644 --- a/frontend/src/components/WatchlistImportDialog.tsx +++ b/frontend/src/components/WatchlistImportDialog.tsx @@ -4,6 +4,7 @@ import { Modal } from '@/components/Modal' import { toast } from '@/components/Toast' import { api, type WatchlistImportCandidate } from '@/lib/api' import { useWatchlistBatchAdd } from '@/lib/useSharedMutations' +import { getOcrInstallHint } from '@/lib/ocrInstallHint' interface Props { open: boolean @@ -12,25 +13,59 @@ interface Props { export function WatchlistImportDialog({ open, onClose }: Props) { const inputRef = useRef(null) + const abortRef = useRef(null) + const genRef = useRef(0) const [busy, setBusy] = useState(false) const [provider, setProvider] = useState('') const [candidates, setCandidates] = useState([]) const [selected, setSelected] = useState>(new Set()) const [previewUrl, setPreviewUrl] = useState(null) + const [ocrAvailable, setOcrAvailable] = useState(null) + const [installHint, setInstallHint] = useState('') const batchAdd = useWatchlistBatchAdd() + const abortInFlight = useCallback(() => { + abortRef.current?.abort() + abortRef.current = null + genRef.current += 1 + }, []) + const reset = useCallback(() => { + abortInFlight() setBusy(false) setCandidates([]) setSelected(new Set()) setProvider('') - if (previewUrl) URL.revokeObjectURL(previewUrl) - setPreviewUrl(null) + setOcrAvailable(null) + setInstallHint('') + setPreviewUrl(prev => { + if (prev) URL.revokeObjectURL(prev) + return null + }) if (inputRef.current) inputRef.current.value = '' - }, [previewUrl]) + }, [abortInFlight]) useEffect(() => { - if (!open) reset() + if (!open) { + reset() + return + } + let cancelled = false + void api.watchlistOcrStatus().then( + res => { + if (cancelled) return + setOcrAvailable(res.available) + if (!res.available) setInstallHint(getOcrInstallHint()) + }, + () => { + if (cancelled) return + setOcrAvailable(false) + setInstallHint(getOcrInstallHint()) + }, + ) + return () => { + cancelled = true + } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps const runRecognize = async (file: File) => { @@ -38,13 +73,21 @@ export function WatchlistImportDialog({ open, onClose }: Props) { toast('请选择图片文件', 'error') return } - if (previewUrl) URL.revokeObjectURL(previewUrl) - setPreviewUrl(URL.createObjectURL(file)) + abortInFlight() + const controller = new AbortController() + abortRef.current = controller + const gen = genRef.current + + setPreviewUrl(prev => { + if (prev) URL.revokeObjectURL(prev) + return URL.createObjectURL(file) + }) setBusy(true) setCandidates([]) setSelected(new Set()) try { - const res = await api.watchlistImportImage(file) + const res = await api.watchlistImportImage(file, controller.signal) + if (gen !== genRef.current) return setProvider(res.provider) setCandidates(res.candidates) const defaults = new Set( @@ -58,10 +101,13 @@ export function WatchlistImportDialog({ open, onClose }: Props) { } else if (res.matched_count === 0) { toast('识别到代码但未能匹配证券主数据', 'error') } - } catch { - /* toast already in request() */ + } catch (err) { + if (gen !== genRef.current) return + if (controller.signal.aborted) return + /* toast already in request() for non-abort errors */ + void err } finally { - setBusy(false) + if (gen === genRef.current) setBusy(false) } } @@ -94,8 +140,8 @@ export function WatchlistImportDialog({ open, onClose }: Props) { return } try { - await batchAdd.mutateAsync(symbols) - toast(`已添加 ${symbols.length} 只自选`, 'success') + const data = await batchAdd.mutateAsync(symbols) + toast(`已添加 ${data.added} 只自选`, 'success') onClose() } catch { /* toast in request */ @@ -104,6 +150,8 @@ export function WatchlistImportDialog({ open, onClose }: Props) { if (!open) return null + const ocrBlocked = ocrAvailable === false + return (

- 上传券商自选列表截图,识别代码后确认添加 + {ocrBlocked + ? 'OCR 引擎不可用' + : '上传券商自选列表截图,识别代码后确认添加'} {provider ? ` · ${provider}` : ''}

@@ -131,99 +181,107 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
- onPick(e.target.files?.[0])} - /> - - - - {previewUrl && ( -
- 预览 + {ocrBlocked ? ( +
+ {installHint}
- )} + ) : ( + <> + onPick(e.target.files?.[0])} + /> - {candidates.length > 0 && ( -
-
- - 识别 {candidates.length} 个代码 · 匹配 {matched.length} · 已选 {selected.size} - - {selectable.length > 0 && ( - +
-
    - {candidates.map(c => { - const key = c.symbol || c.code - const disabled = !c.matched || !c.symbol - const checked = !!(c.symbol && selected.has(c.symbol)) - return ( -
  • -
  • - ) - })} -
-
+ {allSelected ? '取消全选' : '全选可添加'} + + )} +
+
    + {candidates.map(c => { + const key = c.symbol || c.code + const disabled = !c.matched || !c.symbol || c.already_in_watchlist + const checked = !!(c.symbol && selected.has(c.symbol)) + return ( +
  • + +
  • + ) + })} +
+
+ )} + )} @@ -237,7 +295,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) { From 3e150ff600f613033afef10f9fc7c64ec2819699 Mon Sep 17 00:00:00 2001 From: CJohn Date: Sun, 26 Jul 2026 12:51:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(ocr):=20=E6=88=AA=E5=9B=BE=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E5=B9=B6=E4=B8=B2?= =?UTF-8?q?=E8=A1=8C=E8=AF=86=E5=88=AB=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件选择与拖拽支持一次选多张截图,前端按队列逐张调用 import-image,同一时刻只跑一路 OCR,避免加重小内存机器峰值 - 多张识别结果按股票代码合并去重,优先保留已匹配项,已在自选标记取并集 - 展示多图缩略预览与「识别中 x/y」进度;一次最多 10 张 - request 增加 quiet 选项,队列内失败不逐条弹 toast,结束后统一提示 --- .../src/components/WatchlistImportDialog.tsx | 173 ++++++++++++++---- frontend/src/lib/api.ts | 19 +- 2 files changed, 154 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/WatchlistImportDialog.tsx b/frontend/src/components/WatchlistImportDialog.tsx index cc49b90..1e15c71 100644 --- a/frontend/src/components/WatchlistImportDialog.tsx +++ b/frontend/src/components/WatchlistImportDialog.tsx @@ -11,15 +11,52 @@ interface Props { onClose: () => void } +/** 一次最多排队识别的图片数,避免误选大量文件拖垮小内存机器。 */ +const MAX_IMPORT_IMAGES = 10 + +function isImageFile(file: File): boolean { + return file.type.startsWith('image/') || /\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name) +} + +/** 按 code 合并多图 OCR 结果:优先保留已匹配项,已在自选取并集。 */ +export function mergeImportCandidates( + lists: WatchlistImportCandidate[][], +): WatchlistImportCandidate[] { + const byCode = new Map() + for (const list of lists) { + for (const c of list) { + const prev = byCode.get(c.code) + if (!prev) { + byCode.set(c.code, c) + continue + } + if (c.matched && !prev.matched) { + byCode.set(c.code, c) + continue + } + if (c.matched && prev.matched) { + byCode.set(c.code, { + ...prev, + symbol: prev.symbol || c.symbol, + name: prev.name || c.name, + already_in_watchlist: prev.already_in_watchlist || c.already_in_watchlist, + }) + } + } + } + return [...byCode.values()] +} + export function WatchlistImportDialog({ open, onClose }: Props) { const inputRef = useRef(null) const abortRef = useRef(null) const genRef = useRef(0) const [busy, setBusy] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) const [provider, setProvider] = useState('') const [candidates, setCandidates] = useState([]) const [selected, setSelected] = useState>(new Set()) - const [previewUrl, setPreviewUrl] = useState(null) + const [previewUrls, setPreviewUrls] = useState([]) const [ocrAvailable, setOcrAvailable] = useState(null) const [installHint, setInstallHint] = useState('') const batchAdd = useWatchlistBatchAdd() @@ -30,20 +67,25 @@ export function WatchlistImportDialog({ open, onClose }: Props) { genRef.current += 1 }, []) + const revokePreviews = useCallback((urls: string[]) => { + for (const url of urls) URL.revokeObjectURL(url) + }, []) + const reset = useCallback(() => { abortInFlight() setBusy(false) + setProgress(null) setCandidates([]) setSelected(new Set()) setProvider('') setOcrAvailable(null) setInstallHint('') - setPreviewUrl(prev => { - if (prev) URL.revokeObjectURL(prev) - return null + setPreviewUrls(prev => { + revokePreviews(prev) + return [] }) if (inputRef.current) inputRef.current.value = '' - }, [abortInFlight]) + }, [abortInFlight, revokePreviews]) useEffect(() => { if (!open) { @@ -68,51 +110,96 @@ export function WatchlistImportDialog({ open, onClose }: Props) { } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps - const runRecognize = async (file: File) => { - if (!file.type.startsWith('image/') && !/\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name)) { + const runRecognizeQueue = async (files: File[]) => { + const images = files.filter(isImageFile) + if (images.length === 0) { toast('请选择图片文件', 'error') return } + if (images.length < files.length) { + toast('已忽略非图片文件', 'error') + } + const queue = images.slice(0, MAX_IMPORT_IMAGES) + if (images.length > MAX_IMPORT_IMAGES) { + toast(`一次最多识别 ${MAX_IMPORT_IMAGES} 张,已取前 ${MAX_IMPORT_IMAGES} 张`, 'error') + } + abortInFlight() const controller = new AbortController() abortRef.current = controller const gen = genRef.current - setPreviewUrl(prev => { - if (prev) URL.revokeObjectURL(prev) - return URL.createObjectURL(file) + setPreviewUrls(prev => { + revokePreviews(prev) + return queue.map(f => URL.createObjectURL(f)) }) setBusy(true) + setProgress({ done: 0, total: queue.length }) setCandidates([]) setSelected(new Set()) + setProvider('') + + const mergedLists: WatchlistImportCandidate[][] = [] + let lastProvider = '' + let failed = 0 + let lastError = '' + try { - const res = await api.watchlistImportImage(file, controller.signal) + for (let i = 0; i < queue.length; i++) { + if (gen !== genRef.current || controller.signal.aborted) return + try { + // quiet:避免每张失败各弹一条 toast,结束时统一提示 + const res = await api.watchlistImportImage(queue[i], controller.signal, true) + if (gen !== genRef.current) return + lastProvider = res.provider + mergedLists.push(res.candidates) + } catch (err) { + if (gen !== genRef.current) return + if (controller.signal.aborted) return + failed += 1 + lastError = err instanceof Error ? err.message : '' + } + if (gen === genRef.current) { + setProgress({ done: i + 1, total: queue.length }) + } + } + if (gen !== genRef.current) return - setProvider(res.provider) - setCandidates(res.candidates) + + const merged = mergeImportCandidates(mergedLists) + setProvider(lastProvider) + setCandidates(merged) const defaults = new Set( - res.candidates + merged .filter(c => c.matched && c.symbol && !c.already_in_watchlist) .map(c => c.symbol!), ) setSelected(defaults) - if (res.candidates.length === 0) { - toast('未识别到股票代码,请换一张更清晰的自选列表截图', 'error') - } else if (res.matched_count === 0) { + + if (merged.length === 0) { + toast( + lastError + || (failed > 0 + ? '识别失败或未识别到股票代码,请换更清晰的截图' + : '未识别到股票代码,请换一张更清晰的自选列表截图'), + 'error', + ) + } else if (merged.every(c => !c.matched)) { toast('识别到代码但未能匹配证券主数据', 'error') + } else if (failed > 0) { + toast(`有 ${failed} 张识别失败,已合并其余结果`, 'error') } - } catch (err) { - if (gen !== genRef.current) return - if (controller.signal.aborted) return - /* toast already in request() for non-abort errors */ - void err } finally { - if (gen === genRef.current) setBusy(false) + if (gen === genRef.current) { + setBusy(false) + setProgress(null) + } } } - const onPick = (file: File | undefined | null) => { - if (file) void runRecognize(file) + const onPick = (list: FileList | File[] | null | undefined) => { + if (!list || list.length === 0) return + void runRecognizeQueue(Array.from(list)) } const toggle = (symbol: string) => { @@ -151,6 +238,12 @@ export function WatchlistImportDialog({ open, onClose }: Props) { if (!open) return null const ocrBlocked = ocrAvailable === false + const progressLabel = + progress && progress.total > 1 + ? `识别中 ${progress.done}/${progress.total}…` + : progress + ? '识别中…' + : null return ( {ocrBlocked ? 'OCR 引擎不可用' - : '上传券商自选列表截图,识别代码后确认添加'} + : '可多选截图,将逐张识别并合并结果后确认添加'} {provider ? ` · ${provider}` : ''}

@@ -190,9 +283,13 @@ export function WatchlistImportDialog({ open, onClose }: Props) { onPick(e.target.files?.[0])} + onChange={e => { + onPick(e.target.files) + e.target.value = '' + }} /> - {previewUrl && ( -
- 预览 + {previewUrls.length > 0 && ( +
+ {previewUrls.map((url, i) => ( +
+ {`预览 +
+ ))}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b96bc5c..f594b60 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,13 +7,19 @@ import { toast } from '@/components/Toast' const BASE = '' -async function request(path: string, init?: RequestInit): Promise { - const isFormData = init?.body instanceof FormData +type RequestOptions = RequestInit & { + /** 为 true 时不弹错误 toast(由调用方自行汇总提示,如多图串行队列) */ + quiet?: boolean +} + +async function request(path: string, init?: RequestOptions): Promise { + const { quiet, ...fetchInit } = init ?? {} + const isFormData = fetchInit.body instanceof FormData const headers: Record = {} if (!isFormData) headers['Content-Type'] = 'application/json' // 合并调用方传入的 headers (此前会被整体覆盖丢弃) - Object.assign(headers, init?.headers as Record | undefined) - const res = await fetch(`${BASE}${path}`, { ...init, headers }) + Object.assign(headers, fetchInit.headers as Record | undefined) + const res = await fetch(`${BASE}${path}`, { ...fetchInit, headers }) if (!res.ok) { let detail = '' try { @@ -30,7 +36,7 @@ async function request(path: string, init?: RequestInit): Promise { } catch { /* ignore */ } const msg = detail || `${res.status} ${res.statusText}` // 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏 - if (res.status !== 401) toast(msg, 'error') + if (res.status !== 401 && !quiet) toast(msg, 'error') throw new Error(msg) } return res.json() as Promise @@ -1380,13 +1386,14 @@ export const api = { }), watchlistOcrStatus: () => request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'), - watchlistImportImage: (file: File, signal?: AbortSignal) => { + watchlistImportImage: (file: File, signal?: AbortSignal, quiet = false) => { const fd = new FormData() fd.append('file', file) return request('/api/watchlist/import-image', { method: 'POST', body: fd, signal, + quiet, }) }, watchlistRemove: (symbol: string) =>