feat(watchlist): 支持从券商自选截图批量导入

基于 Tesseract OCR 识别六位代码并校验证券主数据,前端提供导入确认弹窗;
修复批量添加后 enriched 缓存未刷新导致需手动刷新页面的问题。
This commit is contained in:
ChenJunheng
2026-07-15 23:25:46 +08:00
parent 77876c7ee4
commit 6f084e1b29
14 changed files with 702 additions and 15 deletions
+9 -6
View File
@@ -76,12 +76,15 @@ WORKDIR /app
# bookworm 自带 nodejs 18.19, 满足插件 engines>=18; --no-install-recommends 精简, # bookworm 自带 nodejs 18.19, 满足插件 engines>=18; --no-install-recommends 精简,
# 自带 libnode/libc-ares 等全部动态依赖, 无需手动补库。 # 自带 libnode/libc-ares 等全部动态依赖, 无需手动补库。
# 国内构建走 apt mirror 已在 debian 镜像sources.list 配好, 无需额外换源。 # 国内构建走 apt mirror 已在 debian 镜像sources.list 配好, 无需额外换源。
RUN if [ "$INCLUDE_STOCKSDK" = "1" ]; then \ # tesseract-ocr: 自选截图导入(始终安装); nodejs: 仅 INCLUDE_STOCKSDK=1 时安装
apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* \ && if [ "$INCLUDE_STOCKSDK" = "1" ]; then \
&& node --version; \ apt-get install -y --no-install-recommends nodejs \
fi && node --version; \
fi \
&& rm -rf /var/lib/apt/lists/* \
&& tesseract --version
# 安装 uv(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源, # 安装 uv(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源,
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。 # 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
+51 -1
View File
@@ -7,15 +7,27 @@ import time
from datetime import date from datetime import date
import polars as pl import polars as pl
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from app.services import watchlist from app.services import watchlist
from app.services.watchlist_ocr import import_watchlist_image
from app.services.watchlist_ocr.provider import get_ocr_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"]) router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
_MAX_IMPORT_IMAGE_BYTES = 12 * 1024 * 1024 # 12MB
_IMPORT_IMAGE_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/bmp",
"image/gif",
}
class AddRequest(BaseModel): class AddRequest(BaseModel):
symbol: str symbol: str
@@ -59,6 +71,44 @@ def add_batch(req: BatchAddRequest, request: Request):
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)} return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
@router.get("/ocr-status")
def ocr_status():
"""当前 OCR 引擎是否可用(前端可据此提示安装依赖)。"""
provider = get_ocr_provider()
return {"provider": provider.name, "available": provider.available()}
@router.post("/import-image")
async def import_from_image(request: Request, file: UploadFile = File(...)):
"""从自选截图识别股票代码,返回候选列表(不自动写入自选)。"""
content_type = (file.content_type or "").split(";")[0].strip().lower()
filename = (file.filename or "").lower()
ok_type = content_type in _IMPORT_IMAGE_TYPES or content_type.startswith("image/")
ok_ext = filename.endswith((".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"))
if not ok_type and not ok_ext:
raise HTTPException(400, "仅支持 JPG / PNG / WebP 等图片")
data = await file.read()
if not data:
raise HTTPException(400, "空文件")
if len(data) > _MAX_IMPORT_IMAGE_BYTES:
raise HTTPException(400, "图片过大(上限 12MB")
existing = {r["symbol"] for r in watchlist.list_symbols()}
data_dir = request.app.state.repo.store.data_dir
try:
result = import_watchlist_image(data, data_dir, existing_symbols=existing)
except RuntimeError as e:
raise HTTPException(503, str(e)) from e
except Exception as e: # noqa: BLE001
logger.exception("watchlist import-image failed")
raise HTTPException(500, f"识别失败: {e}") from e
# 响应不回传整段 raw_text(可能很长);调试时可开 query,这里默认省略
result.pop("raw_text", None)
return result
@router.post("/{symbol}/top") @router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request): def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol) rows = watchlist.move_to_top(symbol)
@@ -0,0 +1,9 @@
"""自选股截图 OCR 导入。
引擎通过 OcrProvider 抽象,默认 Tesseract;后续可换成 RapidOCR 等而不改 API。
"""
from __future__ import annotations
from app.services.watchlist_ocr.pipeline import ImportCandidate, import_watchlist_image
__all__ = ["ImportCandidate", "import_watchlist_image"]
@@ -0,0 +1,152 @@
"""截图 → OCR 文本 → 抽代码 → instruments 校验。"""
from __future__ import annotations
import logging
import re
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import polars as pl
from app.services.watchlist_ocr.provider import OcrProvider, get_ocr_provider
logger = logging.getLogger(__name__)
# A 股 / ETF 六位代码;含 OCR 常见拆分:5881 70 / 5881\n70
_CODE_RE = re.compile(r"(?<!\d)(\d{6})(?!\d)")
_SPLIT_CODE_RE = re.compile(r"(?<!\d)(\d{3,5})\s+(\d{1,3})(?!\d)")
@dataclass
class ImportCandidate:
code: str
symbol: str | None
name: str | None
matched: bool
already_in_watchlist: bool = False
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def extract_codes(text: str) -> list[str]:
"""从 OCR 文本按出现顺序去重抽取六位代码。"""
if not text:
return []
# 先把「5881 70」这类拆分拼回六位,再统一匹配
def _join_split(m: re.Match[str]) -> str:
joined = m.group(1) + m.group(2)
return joined if len(joined) == 6 else m.group(0)
normalized = _SPLIT_CODE_RE.sub(_join_split, text)
seen: set[str] = set()
codes: list[str] = []
for m in _CODE_RE.finditer(normalized):
code = m.group(1)
if code in seen:
continue
seen.add(code)
codes.append(code)
return codes
def build_instrument_lookups(data_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
"""构建 code→symbol、symbol→name(股票 + ETF)。"""
code_to_symbol: dict[str, str] = {}
symbol_to_name: dict[str, str] = {}
paths: list[Path] = [
data_dir / "instruments" / "instruments.parquet",
]
etf_dir = data_dir / "instruments_etf"
if etf_dir.is_dir():
paths.extend(sorted(etf_dir.glob("*.parquet")))
for path in paths:
if not path.exists():
continue
try:
df = pl.read_parquet(path)
if "symbol" not in df.columns:
continue
has_code = "code" in df.columns
has_name = "name" in df.columns
for row in df.iter_rows(named=True):
symbol = str(row.get("symbol") or "").strip()
if not symbol:
continue
code = str(row.get("code") or "").strip() if has_code else ""
if not (len(code) == 6 and code.isdigit()):
bare = symbol.split(".", 1)[0]
if len(bare) == 6 and bare.isdigit():
code = bare
if len(code) == 6 and code.isdigit():
code_to_symbol.setdefault(code, symbol)
if has_name:
name = str(row.get("name") or "").strip()
if name:
symbol_to_name.setdefault(symbol, name)
except Exception as e: # noqa: BLE001
logger.debug("read instruments %s failed: %s", path, e)
return code_to_symbol, symbol_to_name
def resolve_candidates(
codes: list[str],
code_to_symbol: dict[str, str],
symbol_to_name: dict[str, str],
existing_symbols: set[str] | None = None,
) -> list[ImportCandidate]:
existing = existing_symbols or set()
out: list[ImportCandidate] = []
for code in codes:
symbol = code_to_symbol.get(code)
matched = symbol is not None
name = symbol_to_name.get(symbol) if symbol else None
out.append(
ImportCandidate(
code=code,
symbol=symbol,
name=name,
matched=matched,
already_in_watchlist=bool(symbol and symbol in existing),
)
)
return out
def import_watchlist_image(
image_bytes: bytes,
data_dir: Path,
*,
existing_symbols: set[str] | None = None,
provider: OcrProvider | None = None,
) -> dict[str, Any]:
"""识别截图并返回候选列表(不写入自选)。"""
ocr = provider or get_ocr_provider()
if not ocr.available():
raise RuntimeError(
f"OCR 引擎「{ocr.name}」不可用。请安装 TesseractmacOS: brew install tesseract"
"Docker 镜像已内置)。"
)
text = ocr.extract_text(image_bytes)
codes = extract_codes(text)
code_to_symbol, symbol_to_name = build_instrument_lookups(data_dir)
candidates = resolve_candidates(codes, code_to_symbol, symbol_to_name, existing_symbols)
matched = [c for c in candidates if c.matched]
unmatched = [c for c in candidates if not c.matched]
return {
"provider": ocr.name,
"raw_text": text,
"codes": codes,
"candidates": [c.to_dict() for c in candidates],
"matched_count": len(matched),
"unmatched_count": len(unmatched),
}
@@ -0,0 +1,85 @@
"""OCR 引擎抽象层 — 当前实现为 Tesseract。"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from functools import lru_cache
from io import BytesIO
from PIL import Image, ImageEnhance, ImageOps
logger = logging.getLogger(__name__)
class OcrProvider(ABC):
"""OCR 引擎接口。实现类只负责「图 → 文本」,代码抽取与证券匹配在 pipeline 中完成。"""
name: str
@abstractmethod
def extract_text(self, image_bytes: bytes) -> str:
"""从图片字节提取纯文本(可含换行)。"""
@abstractmethod
def available(self) -> bool:
"""运行时依赖是否就绪(二进制/模型等)。"""
def preprocess_for_ocr(image_bytes: bytes) -> Image.Image:
"""暗色券商截图预处理:灰度、反相、增强对比,提升数字识别率。"""
img = Image.open(BytesIO(image_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
gray = ImageOps.grayscale(img)
# 暗底白字 → 白底黑字,Tesseract 更稳
inverted = ImageOps.invert(gray)
contrasted = ImageEnhance.Contrast(inverted).enhance(1.8)
# 小字放大一档
w, h = contrasted.size
if w < 1400:
scale = 1400 / w
contrasted = contrasted.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
return contrasted
class TesseractOcrProvider(OcrProvider):
name = "tesseract"
def available(self) -> bool:
try:
import pytesseract
pytesseract.get_tesseract_version()
return True
except Exception as e: # noqa: BLE001
logger.debug("tesseract unavailable: %s", e)
return False
def extract_text(self, image_bytes: bytes) -> str:
import pytesseract
img = preprocess_for_ocr(image_bytes)
# 优先数字+字母(股票代码);中文语言包可选,缺失时回退 eng
configs = [
("chi_sim+eng", "--psm 6"),
("eng", "--psm 6"),
("eng", "--psm 11"),
]
last_err: Exception | None = None
for lang, cfg in configs:
try:
text = pytesseract.image_to_string(img, lang=lang, config=cfg)
if text and text.strip():
return text
except Exception as e: # noqa: BLE001
last_err = e
logger.debug("tesseract lang=%s failed: %s", lang, e)
if last_err:
raise RuntimeError(f"Tesseract OCR 失败: {last_err}") from last_err
return ""
@lru_cache(maxsize=1)
def get_ocr_provider() -> OcrProvider:
"""返回当前 OCR 引擎(Tesseract)。"""
return TesseractOcrProvider()
+3
View File
@@ -32,6 +32,9 @@ dependencies = [
"platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写) "platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写)
"winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心) "winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心)
"plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux) "plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux)
# Watchlist screenshot OCR (Tesseract)
"pytesseract>=0.3.10",
"Pillow>=10.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+75
View File
@@ -0,0 +1,75 @@
"""自选截图 OCR:代码抽取与 instruments 匹配(不依赖本机 tesseract)。"""
from __future__ import annotations
from pathlib import Path
import polars as pl
from app.services.watchlist_ocr.pipeline import (
extract_codes,
import_watchlist_image,
resolve_candidates,
)
from app.services.watchlist_ocr.provider import OcrProvider
class _FakeOcr(OcrProvider):
name = "fake"
def __init__(self, text: str) -> None:
self._text = text
def available(self) -> bool:
return True
def extract_text(self, image_bytes: bytes) -> str:
return self._text
def test_extract_codes_order_and_dedupe():
text = "563230 融\n515880\n价格 1.340\n563230 重复\nXAUUSD\n601636"
assert extract_codes(text) == ["563230", "515880", "601636"]
def test_extract_codes_joins_ocr_split():
text = "科创半导体\n5881 70 [融]\n创业板\n159382"
assert extract_codes(text) == ["588170", "159382"]
def test_resolve_candidates_matched_and_unmatched():
code_to_symbol = {"600036": "600036.SH", "515880": "515880.SH"}
symbol_to_name = {"600036.SH": "招商银行", "515880.SH": "通信ETF国泰"}
rows = resolve_candidates(
["600036", "999999", "515880"],
code_to_symbol,
symbol_to_name,
existing_symbols={"600036.SH"},
)
assert rows[0].matched and rows[0].already_in_watchlist
assert rows[0].name == "招商银行"
assert not rows[1].matched and rows[1].symbol is None
assert rows[2].matched and not rows[2].already_in_watchlist
def test_import_watchlist_image_with_fake_ocr(tmp_path: Path):
inst = tmp_path / "instruments"
inst.mkdir()
pl.DataFrame(
{
"code": ["600036", "515880"],
"symbol": ["600036.SH", "515880.SH"],
"name": ["招商银行", "通信ETF国泰"],
}
).write_parquet(inst / "instruments.parquet")
fake_text = "招商银行\n600036\n通信ETF\n515880\n伦敦金 XAUUSD"
result = import_watchlist_image(
b"fake-bytes",
tmp_path,
existing_symbols=set(),
provider=_FakeOcr(fake_text),
)
assert result["provider"] == "fake"
assert result["codes"] == ["600036", "515880"]
assert result["matched_count"] == 2
assert result["unmatched_count"] == 0
+17
View File
@@ -1958,6 +1958,19 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
] ]
[[package]]
name = "pytesseract"
version = "0.3.13"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pillow" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.3"
@@ -2501,12 +2514,14 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "openai" }, { name = "openai" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pillow" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "plyer" }, { name = "plyer" },
{ name = "polars" }, { name = "polars" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pytesseract" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pyyaml" }, { name = "pyyaml" },
@@ -2543,6 +2558,7 @@ requires-dist = [
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "openai", specifier = ">=1.40" }, { name = "openai", specifier = ">=1.40" },
{ name = "pandas", specifier = ">=2.2" }, { name = "pandas", specifier = ">=2.2" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "platformdirs", specifier = ">=4.0" }, { name = "platformdirs", specifier = ">=4.0" },
{ name = "plyer", specifier = ">=2.1" }, { name = "plyer", specifier = ">=2.1" },
{ name = "polars", specifier = ">=1.0" }, { name = "polars", specifier = ">=1.0" },
@@ -2550,6 +2566,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=16.0" }, { name = "pyarrow", specifier = ">=16.0" },
{ name = "pydantic", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.4" }, { name = "pydantic-settings", specifier = ">=2.4" },
{ name = "pytesseract", specifier = ">=0.3.10" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
@@ -57,7 +57,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!), mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
}, },
}) })
@@ -0,0 +1,254 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ImagePlus, Loader2, Upload, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { toast } from '@/components/Toast'
import { api, type WatchlistImportCandidate } from '@/lib/api'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
interface Props {
open: boolean
onClose: () => void
}
export function WatchlistImportDialog({ open, onClose }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
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 batchAdd = useWatchlistBatchAdd()
const reset = useCallback(() => {
setBusy(false)
setCandidates([])
setSelected(new Set())
setProvider('')
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewUrl(null)
if (inputRef.current) inputRef.current.value = ''
}, [previewUrl])
useEffect(() => {
if (!open) reset()
}, [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)) {
toast('请选择图片文件', 'error')
return
}
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewUrl(URL.createObjectURL(file))
setBusy(true)
setCandidates([])
setSelected(new Set())
try {
const res = await api.watchlistImportImage(file)
setProvider(res.provider)
setCandidates(res.candidates)
const defaults = new Set(
res.candidates
.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) {
toast('识别到代码但未能匹配证券主数据', 'error')
}
} catch {
/* toast already in request() */
} finally {
setBusy(false)
}
}
const onPick = (file: File | undefined | null) => {
if (file) void runRecognize(file)
}
const toggle = (symbol: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(symbol)) next.delete(symbol)
else next.add(symbol)
return next
})
}
const matched = candidates.filter(c => c.matched && c.symbol)
const selectable = matched.filter(c => !c.already_in_watchlist)
const allSelected = selectable.length > 0 && selectable.every(c => selected.has(c.symbol!))
const toggleAll = () => {
if (allSelected) setSelected(new Set())
else setSelected(new Set(selectable.map(c => c.symbol!)))
}
const confirmAdd = async () => {
const symbols = [...selected]
if (symbols.length === 0) {
toast('请至少选择一只股票', 'error')
return
}
try {
await batchAdd.mutateAsync(symbols)
toast(`已添加 ${symbols.length} 只自选`, 'success')
onClose()
} catch {
/* toast in request */
}
}
if (!open) return null
return (
<Modal
onClose={onClose}
labelledBy="watchlist-import-title"
panelClassName="w-[92vw] max-w-lg max-h-[85vh] flex flex-col bg-surface border border-border rounded-card shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div>
<h2 id="watchlist-import-title" className="text-sm font-semibold text-foreground">
</h2>
<p className="text-[11px] text-muted mt-0.5">
{provider ? ` · ${provider}` : ''}
</p>
</div>
<button
type="button"
onClick={onClose}
className="h-8 w-8 inline-flex items-center justify-center rounded-btn text-secondary hover:bg-elevated"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</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" />
</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"
>
{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
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>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
<button
type="button"
onClick={onClose}
className="h-8 px-3 rounded-btn text-xs text-secondary hover:bg-elevated"
>
</button>
<button
type="button"
disabled={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"
>
{batchAdd.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
({selected.size})
</button>
</div>
</Modal>
)
}
+26
View File
@@ -213,6 +213,22 @@ export interface WatchlistEntry {
name?: string | null name?: string | null
} }
export interface WatchlistImportCandidate {
code: string
symbol: string | null
name: string | null
matched: boolean
already_in_watchlist: boolean
}
export interface WatchlistImportResult {
provider: string
codes: string[]
candidates: WatchlistImportCandidate[]
matched_count: number
unmatched_count: number
}
export interface Quote { export interface Quote {
symbol: string symbol: string
price?: number price?: number
@@ -1305,6 +1321,16 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ symbols, note }), body: JSON.stringify({ symbols, note }),
}), }),
watchlistOcrStatus: () =>
request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'),
watchlistImportImage: (file: File) => {
const fd = new FormData()
fd.append('file', file)
return request<WatchlistImportResult>('/api/watchlist/import-image', {
method: 'POST',
body: fd,
})
},
watchlistRemove: (symbol: string) => watchlistRemove: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>( request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}`, `/api/watchlist/${encodeURIComponent(symbol)}`,
+4 -2
View File
@@ -29,14 +29,16 @@ export function useUpdateQuoteInterval() {
}) })
} }
/** 批量添加自选 — Screener / Intraday 共用 */ /** 批量添加自选 — Screener / Intraday / 截图导入 共用 */
export function useWatchlistBatchAdd() { export function useWatchlistBatchAdd() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols), mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) // 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam],
// 不能用 QK.watchlistEnriched()(= undefined) 精确匹配, 否则列表不刷新。
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
}, },
}) })
} }
+1 -1
View File
@@ -499,7 +499,7 @@ export function Screener() {
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol), inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
}, },
}) })
+15 -4
View File
@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react' import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw } from 'lucide-react' import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api' import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage' import { storage } from '@/lib/storage'
@@ -9,6 +9,7 @@ import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { WatchlistImportDialog } from '@/components/WatchlistImportDialog'
import { ColumnCustomizer } from '@/components/ColumnCustomizer' import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable' import { StockDataTable } from '@/components/stock-table/StockDataTable'
import { useTableSort } from '@/components/stock-table/useTableSort' import { useTableSort } from '@/components/stock-table/useTableSort'
@@ -510,6 +511,7 @@ export function Watchlist() {
// 列配置 — 从后端/localStorage 异步加载 // 列配置 — 从后端/localStorage 异步加载
const [columns, setColumns] = useState<ColumnConfig[]>([...BUILTIN_COLUMNS]) const [columns, setColumns] = useState<ColumnConfig[]>([...BUILTIN_COLUMNS])
const [customizerOpen, setCustomizerOpen] = useState(false) const [customizerOpen, setCustomizerOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const columnsLoaded = useRef(false) const columnsLoaded = useRef(false)
useEffect(() => { useEffect(() => {
@@ -659,7 +661,7 @@ export function Watchlist() {
}) })
// 2. 清除 list 缓存,触发后台 refetch // 2. 清除 list 缓存,触发后台 refetch
qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
}, },
}) })
@@ -683,7 +685,7 @@ export function Watchlist() {
// 立即清空 enriched 缓存 // 立即清空 enriched 缓存
qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 }) qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 })
qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
}, },
}) })
@@ -905,6 +907,13 @@ export function Watchlist() {
existingSymbols={allSymbols as string[]} existingSymbols={allSymbols as string[]}
onAdd={(sym) => addMutation.mutate(sym)} 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="从截图导入自选"
>
<ImagePlus className="h-4 w-4" />
</button>
<div className="w-px h-5 bg-border" /> <div className="w-px h-5 bg-border" />
{/* 视图 */} {/* 视图 */}
<button <button
@@ -1030,7 +1039,7 @@ export function Watchlist() {
<EmptyState <EmptyState
icon={Star} icon={Star}
title="自选股为空" title="自选股为空"
hint="点击右上角搜索按钮查找并预览标的,进入个股详情后可添加到自选。" hint="点击右上角搜索添加标的,或点击图片图标从券商自选截图批量导入。"
/> />
) : viewMode === 'table' ? ( ) : viewMode === 'table' ? (
<StockDataTable <StockDataTable
@@ -1327,6 +1336,8 @@ export function Watchlist() {
name={previewName} name={previewName}
onClose={closePreview} onClose={closePreview}
/> />
<WatchlistImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div> </div>
) )
} }