Merge pull request #143 from CJ0Hn/feat/watchlist-screenshot-import

fix: 自选截图导入优化
This commit is contained in:
wshy
2026-07-31 19:11:12 +08:00
committed by GitHub
8 changed files with 572 additions and 136 deletions
+11 -4
View File
@@ -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}」不可用。请安装 TesseractmacOS: 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:
+182 -4
View File
@@ -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"}
+281 -114
View File
@@ -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
View File
@@ -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) =>
+41
View File
@@ -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')
}
+33 -3
View File
@@ -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>