mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Merge pull request #143 from CJ0Hn/feat/watchlist-screenshot-import
fix: 自选截图导入优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -4,69 +4,202 @@ 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
|
||||
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<string, WatchlistImportCandidate>()
|
||||
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<HTMLInputElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const genRef = useRef(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [provider, setProvider] = useState<string>('')
|
||||
const [candidates, setCandidates] = useState<WatchlistImportCandidate[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
|
||||
const [installHint, setInstallHint] = useState('')
|
||||
const batchAdd = useWatchlistBatchAdd()
|
||||
|
||||
const abortInFlight = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
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('')
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
setPreviewUrl(null)
|
||||
setOcrAvailable(null)
|
||||
setInstallHint('')
|
||||
setPreviewUrls(prev => {
|
||||
revokePreviews(prev)
|
||||
return []
|
||||
})
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}, [previewUrl])
|
||||
}, [abortInFlight, revokePreviews])
|
||||
|
||||
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) => {
|
||||
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 (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
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
|
||||
|
||||
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)
|
||||
setProvider(res.provider)
|
||||
setCandidates(res.candidates)
|
||||
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
|
||||
|
||||
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 {
|
||||
/* toast already in request() */
|
||||
} finally {
|
||||
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) => {
|
||||
@@ -94,8 +227,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 +237,14 @@ 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 (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
@@ -116,7 +257,9 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
|
||||
从截图导入自选
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
上传券商自选列表截图,识别代码后确认添加
|
||||
{ocrBlocked
|
||||
? 'OCR 引擎不可用'
|
||||
: '可多选截图,将逐张识别并合并结果后确认添加'}
|
||||
{provider ? ` · ${provider}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -131,99 +274,123 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1 space-y-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/bmp,image/gif,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={e => onPick(e.target.files?.[0])}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
onPick(e.dataTransfer.files?.[0])
|
||||
}}
|
||||
className="w-full flex flex-col items-center justify-center gap-2 rounded-btn border border-dashed border-border bg-elevated/40 hover:bg-elevated/70 px-4 py-6 text-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
) : (
|
||||
<ImagePlus className="h-6 w-6 text-accent" />
|
||||
)}
|
||||
<span className="text-xs">
|
||||
{busy ? '识别中…' : '点击选择或拖拽截图到此处'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="rounded-btn overflow-hidden border border-border bg-black/40 max-h-40">
|
||||
<img src={previewUrl} alt="预览" className="w-full h-full object-contain max-h-40" />
|
||||
{ocrBlocked ? (
|
||||
<div className="rounded-btn border border-border bg-elevated/40 px-4 py-5 text-xs text-secondary leading-relaxed whitespace-pre-wrap">
|
||||
{installHint}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,image/bmp,image/gif,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
onPick(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary">
|
||||
识别 {candidates.length} 个代码 · 匹配 {matched.length} · 已选 {selected.size}
|
||||
</span>
|
||||
{selectable.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选可添加'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || ocrAvailable === null}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
onPick(e.dataTransfer.files)
|
||||
}}
|
||||
className="w-full flex flex-col items-center justify-center gap-2 rounded-btn border border-dashed border-border bg-elevated/40 hover:bg-elevated/70 px-4 py-6 text-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{busy || ocrAvailable === null ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
) : (
|
||||
<ImagePlus className="h-6 w-6 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60 rounded-btn border border-border overflow-hidden">
|
||||
{candidates.map(c => {
|
||||
const key = c.symbol || c.code
|
||||
const disabled = !c.matched || !c.symbol
|
||||
const checked = !!(c.symbol && selected.has(c.symbol))
|
||||
return (
|
||||
<li key={key}>
|
||||
<label
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-elevated/50'
|
||||
}`}
|
||||
<span className="text-xs">
|
||||
{progressLabel
|
||||
?? (ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图(支持多选)')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{previewUrls.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{previewUrls.map((url, i) => (
|
||||
<div
|
||||
key={url}
|
||||
className="shrink-0 w-20 h-20 rounded-btn overflow-hidden border border-border bg-black/40"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`预览 ${i + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary">
|
||||
识别 {candidates.length} 个代码 · 匹配 {matched.length} · 已选 {selected.size}
|
||||
</span>
|
||||
{selectable.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={() => c.symbol && toggle(c.symbol)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{c.name || (c.matched ? c.symbol : '未匹配')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted tabular-nums shrink-0">
|
||||
{c.code}
|
||||
{c.symbol ? ` · ${c.symbol}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{c.already_in_watchlist && (
|
||||
<span className="text-[10px] text-muted">已在自选</span>
|
||||
)}
|
||||
{!c.matched && (
|
||||
<span className="text-[10px] text-warning/90">主数据未找到,已跳过</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{allSelected ? '取消全选' : '全选可添加'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60 rounded-btn border border-border overflow-hidden">
|
||||
{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 (
|
||||
<li key={key}>
|
||||
<label
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-elevated/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={() => c.symbol && toggle(c.symbol)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{c.name || (c.matched ? c.symbol : '未匹配')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted tabular-nums shrink-0">
|
||||
{c.code}
|
||||
{c.symbol ? ` · ${c.symbol}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{c.already_in_watchlist && (
|
||||
<span className="text-[10px] text-muted">已在自选</span>
|
||||
)}
|
||||
{!c.matched && (
|
||||
<span className="text-[10px] text-warning/90">主数据未找到,已跳过</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -237,7 +404,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.size === 0 || batchAdd.isPending || busy}
|
||||
disabled={ocrBlocked || selected.size === 0 || batchAdd.isPending || busy}
|
||||
onClick={() => void confirmAdd()}
|
||||
className="h-8 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
|
||||
>
|
||||
|
||||
+14
-6
@@ -7,13 +7,19 @@ import { toast } from '@/components/Toast'
|
||||
|
||||
const BASE = ''
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const isFormData = init?.body instanceof FormData
|
||||
type RequestOptions = RequestInit & {
|
||||
/** 为 true 时不弹错误 toast(由调用方自行汇总提示,如多图串行队列) */
|
||||
quiet?: boolean
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestOptions): Promise<T> {
|
||||
const { quiet, ...fetchInit } = init ?? {}
|
||||
const isFormData = fetchInit.body instanceof FormData
|
||||
const headers: Record<string, string> = {}
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json'
|
||||
// 合并调用方传入的 headers (此前会被整体覆盖丢弃)
|
||||
Object.assign(headers, init?.headers as Record<string, string> | undefined)
|
||||
const res = await fetch(`${BASE}${path}`, { ...init, headers })
|
||||
Object.assign(headers, fetchInit.headers as Record<string, string> | undefined)
|
||||
const res = await fetch(`${BASE}${path}`, { ...fetchInit, headers })
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
@@ -30,7 +36,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
} 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<T>
|
||||
@@ -1383,12 +1389,14 @@ export const api = {
|
||||
}),
|
||||
watchlistOcrStatus: () =>
|
||||
request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'),
|
||||
watchlistImportImage: (file: File) => {
|
||||
watchlistImportImage: (file: File, signal?: AbortSignal, quiet = false) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request<WatchlistImportResult>('/api/watchlist/import-image', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal,
|
||||
quiet,
|
||||
})
|
||||
},
|
||||
watchlistRemove: (symbol: string) =>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 按当前运行平台给出 Tesseract 安装说明(桌面端未捆绑二进制时使用)。 */
|
||||
export function getOcrInstallHint(): string {
|
||||
const platform =
|
||||
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData
|
||||
?.platform ||
|
||||
navigator.platform ||
|
||||
''
|
||||
const p = platform.toLowerCase()
|
||||
|
||||
if (p.includes('win')) {
|
||||
return [
|
||||
'OCR 引擎不可用:请安装 Tesseract 后重试。',
|
||||
'',
|
||||
'Windows:',
|
||||
'• 下载 UB Mannheim 发行版并安装(勾选将 tesseract 加入 PATH)',
|
||||
'• 或执行:choco install tesseract',
|
||||
'',
|
||||
'安装后重启应用。',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (p.includes('mac')) {
|
||||
return [
|
||||
'OCR 引擎不可用:请安装 Tesseract 后重试。',
|
||||
'',
|
||||
'macOS:',
|
||||
'• 执行:brew install tesseract',
|
||||
'',
|
||||
'安装后重启应用。',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'OCR 引擎不可用:请安装 Tesseract 后重试。',
|
||||
'',
|
||||
'Linux:安装 tesseract-ocr(及语言包)',
|
||||
'Docker:官方镜像已内置,请确认使用对应镜像。',
|
||||
'',
|
||||
'安装后重启应用。',
|
||||
].join('\n')
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DimensionMembersTarget,
|
||||
} from '@/components/DimensionMembersDialog'
|
||||
import { WatchlistImportDialog } from '@/components/WatchlistImportDialog'
|
||||
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
|
||||
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
|
||||
import { StockDataTable } from '@/components/stock-table/StockDataTable'
|
||||
import { VIRTUAL_LIST_THRESHOLD, useParentScroll } from '@/components/virtual-list/useParentScroll'
|
||||
@@ -570,6 +571,8 @@ export function Watchlist() {
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>([...BUILTIN_COLUMNS])
|
||||
const [customizerOpen, setCustomizerOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
|
||||
const [ocrInstallHint, setOcrInstallHint] = useState('')
|
||||
const columnsLoaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -578,6 +581,25 @@ export function Watchlist() {
|
||||
loadColumnConfig().then(setColumns)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.watchlistOcrStatus().then(
|
||||
res => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(res.available)
|
||||
if (!res.available) setOcrInstallHint(getOcrInstallHint())
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(false)
|
||||
setOcrInstallHint(getOcrInstallHint())
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
|
||||
setColumns(next)
|
||||
saveColumnConfig(next)
|
||||
@@ -1004,9 +1026,17 @@ export function Watchlist() {
|
||||
onAdd={(sym) => addMutation.mutate(sym)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth"
|
||||
title="从截图导入自选"
|
||||
onClick={() => {
|
||||
if (ocrAvailable === false) return
|
||||
setImportOpen(true)
|
||||
}}
|
||||
disabled={ocrAvailable === false}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-elevated disabled:hover:text-secondary"
|
||||
title={
|
||||
ocrAvailable === false
|
||||
? ocrInstallHint || 'OCR 不可用,请先安装 Tesseract'
|
||||
: '从截图导入自选'
|
||||
}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user