mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(watchlist): 支持从券商自选截图批量导入
基于 Tesseract OCR 识别六位代码并校验证券主数据,前端提供导入确认弹窗; 修复批量添加后 enriched 缓存未刷新导致需手动刷新页面的问题。
This commit is contained in:
+9
-6
@@ -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(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源,
|
||||||
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
|
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
|
||||||
|
|||||||
@@ -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}」不可用。请安装 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,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()
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
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" },
|
{ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)}`,
|
||||||
|
|||||||
@@ -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'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user