diff --git a/Dockerfile b/Dockerfile index 77649d3..cb295bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源, # 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。 diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index ef984e1..50a9458 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -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,44 @@ 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(...)): + """从自选截图识别股票代码,返回候选列表(不自动写入自选)。""" + 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") def move_one_to_top(symbol: str, request: Request): rows = watchlist.move_to_top(symbol) diff --git a/backend/app/services/watchlist_ocr/__init__.py b/backend/app/services/watchlist_ocr/__init__.py new file mode 100644 index 0000000..6185dea --- /dev/null +++ b/backend/app/services/watchlist_ocr/__init__.py @@ -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"] diff --git a/backend/app/services/watchlist_ocr/pipeline.py b/backend/app/services/watchlist_ocr/pipeline.py new file mode 100644 index 0000000..f47f0c5 --- /dev/null +++ b/backend/app/services/watchlist_ocr/pipeline.py @@ -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"(? 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), + } diff --git a/backend/app/services/watchlist_ocr/provider.py b/backend/app/services/watchlist_ocr/provider.py new file mode 100644 index 0000000..4d33148 --- /dev/null +++ b/backend/app/services/watchlist_ocr/provider.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cd04c37..f5a1422 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/test_watchlist_ocr.py b/backend/tests/test_watchlist_ocr.py new file mode 100644 index 0000000..c91eead --- /dev/null +++ b/backend/tests/test_watchlist_ocr.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index f740189..3b8411a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index b2c25cd..b76598b 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -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'] }) }, }) diff --git a/frontend/src/components/WatchlistImportDialog.tsx b/frontend/src/components/WatchlistImportDialog.tsx new file mode 100644 index 0000000..85ce41c --- /dev/null +++ b/frontend/src/components/WatchlistImportDialog.tsx @@ -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(null) + const [busy, setBusy] = useState(false) + const [provider, setProvider] = useState('') + const [candidates, setCandidates] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [previewUrl, setPreviewUrl] = useState(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 ( + +
+
+

+ 从截图导入自选 +

+

+ 上传券商自选列表截图,识别代码后确认添加 + {provider ? ` · ${provider}` : ''} +

+
+ +
+ +
+ onPick(e.target.files?.[0])} + /> + + + + {previewUrl && ( +
+ 预览 +
+ )} + + {candidates.length > 0 && ( +
+
+ + 识别 {candidates.length} 个代码 · 匹配 {matched.length} · 已选 {selected.size} + + {selectable.length > 0 && ( + + )} +
+
    + {candidates.map(c => { + const key = c.symbol || c.code + const disabled = !c.matched || !c.symbol + const checked = !!(c.symbol && selected.has(c.symbol)) + return ( +
  • + +
  • + ) + })} +
+
+ )} +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0d8ce2f..4873d58 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('/api/watchlist/import-image', { + method: 'POST', + body: fd, + }) + }, watchlistRemove: (symbol: string) => request<{ symbols: WatchlistEntry[] }>( `/api/watchlist/${encodeURIComponent(symbol)}`, diff --git a/frontend/src/lib/useSharedMutations.ts b/frontend/src/lib/useSharedMutations.ts index e8c4321..0937cba 100644 --- a/frontend/src/lib/useSharedMutations.ts +++ b/frontend/src/lib/useSharedMutations.ts @@ -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'] }) }, }) } diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 760117d..a2e6740 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -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'] }) }, }) diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 0586a22..6885f71 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -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([...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)} /> +
{/* 视图 */}
) }