fix(ocr): 完善截图导入的安全限制与交互状态

- 在 Pillow 完整解码前检查图片像素数,并将 DecompressionBombError
  转为明确的参数错误,避免压缩大图在校验前占用过多内存
- 为 OCR 线程任务设置独立的 AnyIO CapacityLimiter(2),最多同时执行
  两次图片解码与 Tesseract 识别,其余请求排队等待
- 前端调用 ocr-status 检查 Tesseract 是否可用;不可用时禁用截图导入
  入口,并按 Windows、macOS 和 Linux 显示对应安装说明
- 关闭弹窗或重新选择图片时取消旧请求,并通过请求代次校验忽略迟到
  的异步结果,防止旧候选回写到新弹窗
- 禁用已存在于自选列表中的候选项,批量添加接口返回实际净新增数量,
  成功提示使用后端返回值
- 补充图片类型与大小校验、解压炸弹、OCR 状态、并发限制以及批量添加
  数量等测试
This commit is contained in:
CJohn
2026-07-26 12:19:55 +08:00
parent 6f1c4032bb
commit e526d25fd0
8 changed files with 440 additions and 120 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"}
+161 -103
View File
@@ -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<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const genRef = useRef(0)
const [busy, setBusy] = useState(false)
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 [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 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 (
<Modal
onClose={onClose}
@@ -116,7 +164,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 +181,107 @@ 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"
accept="image/jpeg,image/png,image/webp,image/bmp,image/gif,.jpg,.jpeg,.png"
className="hidden"
onChange={e => onPick(e.target.files?.[0])}
/>
{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?.[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 || 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">
{busy ? '识别中…' : ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图到此处'}
</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" />
</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 +295,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"
>
+2 -1
View File
@@ -1380,12 +1380,13 @@ export const api = {
}),
watchlistOcrStatus: () =>
request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'),
watchlistImportImage: (file: File) => {
watchlistImportImage: (file: File, signal?: AbortSignal) => {
const fd = new FormData()
fd.append('file', file)
return request<WatchlistImportResult>('/api/watchlist/import-image', {
method: 'POST',
body: fd,
signal,
})
},
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>