mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
Merge pull request #129 from CJ0Hn/feat/watchlist-screenshot-import
feat(watchlist): 支持从券商自选截图批量导入
This commit is contained in:
+9
-6
@@ -76,12 +76,15 @@ WORKDIR /app
|
||||
# bookworm 自带 nodejs 18.19, 满足插件 engines>=18; --no-install-recommends 精简,
|
||||
# 自带 libnode/libc-ares 等全部动态依赖, 无需手动补库。
|
||||
# 国内构建走 apt mirror 已在 debian 镜像sources.list 配好, 无需额外换源。
|
||||
RUN if [ "$INCLUDE_STOCKSDK" = "1" ]; then \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& node --version; \
|
||||
fi
|
||||
# tesseract-ocr: 自选截图导入(始终安装); nodejs: 仅 INCLUDE_STOCKSDK=1 时安装
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
|
||||
&& if [ "$INCLUDE_STOCKSDK" = "1" ]; then \
|
||||
apt-get install -y --no-install-recommends nodejs \
|
||||
&& node --version; \
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& tesseract --version
|
||||
|
||||
# 安装 uv(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源,
|
||||
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
|
||||
|
||||
@@ -7,15 +7,27 @@ import time
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
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__)
|
||||
|
||||
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):
|
||||
symbol: str
|
||||
@@ -59,6 +71,52 @@ def add_batch(req: BatchAddRequest, request: Request):
|
||||
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(...)):
|
||||
"""从自选截图识别股票代码,返回候选列表(不自动写入自选)。"""
|
||||
import anyio
|
||||
|
||||
content_type = (file.content_type or "").split(";")[0].strip().lower()
|
||||
filename = (file.filename or "").lower()
|
||||
# 严格白名单:不接受任意 image/*(如 image/svg+xml)
|
||||
ok_type = content_type in _IMPORT_IMAGE_TYPES
|
||||
ok_ext = filename.endswith((".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"))
|
||||
if not ok_type and not ok_ext:
|
||||
raise HTTPException(400, "仅支持 JPG / PNG / WebP / BMP / GIF 图片")
|
||||
|
||||
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:
|
||||
# OCR 为同步 CPU/子进程;丢进线程池,避免卡住事件循环(行情 SSE 等)
|
||||
result = await anyio.to_thread.run_sync(
|
||||
lambda: import_watchlist_image(data, data_dir, existing_symbols=existing),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
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")
|
||||
def move_one_to_top(symbol: str, request: Request):
|
||||
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}」不可用。请安装 Tesseract(macOS: 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,108 @@
|
||||
"""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__)
|
||||
|
||||
# 像素上限:防 JPEG 高分辨率解码后 OOM(字节上限挡不住高压缩大图)
|
||||
_MAX_PIXELS = 12_000_000
|
||||
# 长边上限:过大则降采样,减内存并加快 OCR
|
||||
_MAX_EDGE = 2000
|
||||
# 过窄时适度放大,提升小字识别率
|
||||
_MIN_WIDTH = 1400
|
||||
|
||||
|
||||
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:
|
||||
"""暗色券商截图预处理:像素上限、降采样、灰度、反相、增强对比。
|
||||
|
||||
Raises:
|
||||
ValueError: 图片分辨率过高(像素数超限)。
|
||||
"""
|
||||
img = Image.open(BytesIO(image_bytes))
|
||||
img.load() # 强制解码,便于尽早失败 / 量尺寸
|
||||
|
||||
pixels = img.width * img.height
|
||||
if pixels > _MAX_PIXELS:
|
||||
raise ValueError("图片分辨率过高,请裁剪后重试")
|
||||
|
||||
# 大图降采样:既减内存又提速 OCR(只放大不缩小的旧逻辑已去掉)
|
||||
if max(img.size) > _MAX_EDGE:
|
||||
img.thumbnail((_MAX_EDGE, _MAX_EDGE), Image.Resampling.LANCZOS)
|
||||
|
||||
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 < _MIN_WIDTH:
|
||||
scale = _MIN_WIDTH / 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()
|
||||
@@ -32,6 +32,9 @@ dependencies = [
|
||||
"platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写)
|
||||
"winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心)
|
||||
"plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux)
|
||||
# Watchlist screenshot OCR (Tesseract)
|
||||
"pytesseract>=0.3.10",
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""自选截图 OCR:代码抽取与 instruments 匹配(不依赖本机 tesseract)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from app.services.watchlist_ocr.pipeline import (
|
||||
extract_codes,
|
||||
import_watchlist_image,
|
||||
resolve_candidates,
|
||||
)
|
||||
from app.services.watchlist_ocr.provider import OcrProvider, preprocess_for_ocr
|
||||
|
||||
|
||||
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 _png_bytes(width: int, height: int) -> bytes:
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (width, height), color=(20, 20, 20)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_preprocess_rejects_excessive_pixels():
|
||||
# 4000×8000 = 32M 像素 > 12M 上限
|
||||
with pytest.raises(ValueError, match="分辨率过高"):
|
||||
preprocess_for_ocr(_png_bytes(4000, 8000))
|
||||
|
||||
|
||||
def test_preprocess_downsamples_large_edge():
|
||||
# 2500×1000 = 2.5M 像素未超限,但长边 > 2000,应降采样
|
||||
out = preprocess_for_ocr(_png_bytes(2500, 1000))
|
||||
assert max(out.size) <= 2000
|
||||
Generated
+17
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
@@ -2501,12 +2514,14 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "openai" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pillow" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "plyer" },
|
||||
{ name = "polars" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pytesseract" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -2543,6 +2558,7 @@ requires-dist = [
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "openai", specifier = ">=1.40" },
|
||||
{ name = "pandas", specifier = ">=2.2" },
|
||||
{ name = "pillow", specifier = ">=10.0" },
|
||||
{ name = "platformdirs", specifier = ">=4.0" },
|
||||
{ name = "plyer", specifier = ">=2.1" },
|
||||
{ name = "polars", specifier = ">=1.0" },
|
||||
@@ -2550,6 +2566,7 @@ requires-dist = [
|
||||
{ name = "pyarrow", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.4" },
|
||||
{ name = "pytesseract", specifier = ">=0.3.10" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ 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!),
|
||||
onSuccess: () => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -213,6 +213,22 @@ export interface WatchlistEntry {
|
||||
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 {
|
||||
symbol: string
|
||||
price?: number
|
||||
@@ -1305,6 +1321,16 @@ export const api = {
|
||||
method: 'POST',
|
||||
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) =>
|
||||
request<{ symbols: WatchlistEntry[] }>(
|
||||
`/api/watchlist/${encodeURIComponent(symbol)}`,
|
||||
|
||||
@@ -29,14 +29,16 @@ export function useUpdateQuoteInterval() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
||||
/** 批量添加自选 — Screener / Intraday / 截图导入 共用 */
|
||||
export function useWatchlistBatchAdd() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
// 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam],
|
||||
// 不能用 QK.watchlistEnriched()(= undefined) 精确匹配, 否则列表不刷新。
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@ export function Screener() {
|
||||
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
@@ -9,6 +9,7 @@ import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { WatchlistImportDialog } from '@/components/WatchlistImportDialog'
|
||||
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
|
||||
import { StockDataTable } from '@/components/stock-table/StockDataTable'
|
||||
import { useTableSort } from '@/components/stock-table/useTableSort'
|
||||
@@ -510,6 +511,7 @@ export function Watchlist() {
|
||||
// 列配置 — 从后端/localStorage 异步加载
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>([...BUILTIN_COLUMNS])
|
||||
const [customizerOpen, setCustomizerOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const columnsLoaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -659,7 +661,7 @@ export function Watchlist() {
|
||||
})
|
||||
// 2. 清除 list 缓存,触发后台 refetch
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
|
||||
},
|
||||
})
|
||||
@@ -683,7 +685,7 @@ export function Watchlist() {
|
||||
// 立即清空 enriched 缓存
|
||||
qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
|
||||
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
|
||||
},
|
||||
})
|
||||
@@ -905,6 +907,13 @@ export function Watchlist() {
|
||||
existingSymbols={allSymbols as string[]}
|
||||
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" />
|
||||
{/* 视图 */}
|
||||
<button
|
||||
@@ -1030,7 +1039,7 @@ export function Watchlist() {
|
||||
<EmptyState
|
||||
icon={Star}
|
||||
title="自选股为空"
|
||||
hint="点击右上角搜索按钮查找并预览标的,进入个股详情后可添加到自选。"
|
||||
hint="点击右上角搜索添加标的,或点击图片图标从券商自选截图批量导入。"
|
||||
/>
|
||||
) : viewMode === 'table' ? (
|
||||
<StockDataTable
|
||||
@@ -1327,6 +1336,8 @@ export function Watchlist() {
|
||||
name={previewName}
|
||||
onClose={closePreview}
|
||||
/>
|
||||
|
||||
<WatchlistImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user