Merge pull request #228 from thinkbuf/feat/watchlist-import-csv-groups

feat(watchlist): batch import CSV / pasted codes into groups
This commit is contained in:
wshy
2026-09-02 15:55:18 +08:00
committed by GitHub
8 changed files with 1291 additions and 290 deletions
+84 -1
View File
@@ -5,6 +5,7 @@ import logging
import math
import time
from datetime import date
from typing import Callable
import anyio
import polars as pl
@@ -13,6 +14,7 @@ from pydantic import BaseModel
from app.db_safe import is_valid_ext_ident, quote_ident
from app.services import watchlist
from app.services.watchlist_csv import import_watchlist_codes, import_watchlist_csv
from app.services.watchlist_ocr import import_watchlist_image
from app.services.watchlist_ocr.provider import get_ocr_provider
@@ -31,6 +33,13 @@ _IMPORT_IMAGE_TYPES = {
}
# OCR 独立并发上限:避免多张大图同时解码 + 多 Tesseract 子进程
_OCR_LIMITER = anyio.CapacityLimiter(2)
# CSV/TXT 导入:文本远小于截图,上限 5MB 足够
_MAX_IMPORT_CSV_BYTES = 5 * 1024 * 1024
_IMPORT_CSV_TYPES = {
"text/csv",
"text/plain",
"application/csv",
}
class AddRequest(BaseModel):
@@ -43,6 +52,7 @@ class BatchAddRequest(BaseModel):
symbols: list[str]
note: str = ""
group_id: str | None = None
group_ids: list[str] | None = None
class GroupNameRequest(BaseModel):
@@ -58,6 +68,10 @@ class GroupAssignRequest(BaseModel):
group_id: str | None = None
class ImportCodesRequest(BaseModel):
text: str
def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
@@ -89,7 +103,12 @@ def add_one(req: AddRequest, request: Request):
@router.post("/batch")
def add_batch(req: BatchAddRequest, request: Request):
try:
rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id)
rows, added = watchlist.add_batch(
req.symbols,
req.note,
group_id=req.group_id,
group_ids=req.group_ids,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request), "added": added}
@@ -194,6 +213,70 @@ async def import_from_image(request: Request, file: UploadFile = File(...)):
return result
def _run_candidate_import(parse: Callable[[], dict], empty_msg: str) -> dict:
"""执行候选解析:ValueError→400、其他→500、空候选→400、剥离 raw_text。"""
try:
result = parse()
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e: # noqa: BLE001
logger.exception("watchlist import failed")
raise HTTPException(500, f"解析失败: {e}") from e
if not result["candidates"]:
raise HTTPException(400, empty_msg)
result.pop("raw_text", None)
return result
@router.post("/import-csv")
async def import_from_csv(request: Request, file: UploadFile = File(...)):
"""从 CSV / TXT 导入自选候选列表(不自动写入自选)。
兼容同花顺/东财/通达信导出(逗号或 Tab 分隔、UTF-8 或 GBK 编码)。目标分组
在候选确认时由前端传入 batch 接口,本端点只做解析与主数据校验。
"""
content_type = (file.content_type or "").split(";")[0].strip().lower()
filename = (file.filename or "").lower()
ok_type = content_type in _IMPORT_CSV_TYPES
ok_ext = filename.endswith((".csv", ".txt"))
if not ok_type and not ok_ext:
raise HTTPException(400, "仅支持 CSV / TXT 文件")
data = await file.read()
if not data:
raise HTTPException(400, "空文件")
if len(data) > _MAX_IMPORT_CSV_BYTES:
raise HTTPException(400, "文件过大(上限 5MB")
data_dir = request.app.state.repo.store.data_dir
# 解码与自选/instruments parquet 读取为同步 CPU/IO,挪线程池避免卡事件循环
return await anyio.to_thread.run_sync(
lambda: _run_candidate_import(
lambda: import_watchlist_csv(
data,
data_dir,
existing_symbols={r["symbol"] for r in watchlist.list_symbols()},
),
"文件中未识别到股票代码或名称",
)
)
@router.post("/import-codes")
def import_from_codes(req: ImportCodesRequest, request: Request):
"""从粘贴的证券代码导入自选候选列表(不自动写入自选)。"""
text = req.text.strip()
if not text:
raise HTTPException(400, "请输入要导入的股票代码")
existing = {r["symbol"] for r in watchlist.list_symbols()}
data_dir = request.app.state.repo.store.data_dir
return _run_candidate_import(
lambda: import_watchlist_codes(text, data_dir, existing_symbols=existing),
"未识别到股票代码",
)
@router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol)
+14 -5
View File
@@ -183,15 +183,23 @@ def add_batch(
symbols: list[str],
note: str = "",
group_id: str | None = None,
group_ids: list[str] | None = None,
) -> tuple[list[dict], int]:
"""批量添加并保持既有语义:每个新处理的标的移动到列表最前面。
group_id 为可选的初始分组(如从某分组页添加时); 重复添加的标的保留
既有全部分组, 仅在显式传入 group_id 且尚未属于该组时并入。
分组为可选的初始分组:``group_id`` 单组(如从某分组页添加)或 ``group_ids``
多组(如批量导入同时并入多个分组)。重复添加的标的保留既有全部分组,
仅把尚未属于的传入分组并入;二者可同时使用、内部去重。
"""
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
# 合并单/多组参数并去重;逐组校验存在性
apply_ids: list[str] = []
for gid in (group_ids or []) + ([group_id] if group_id is not None else []):
if gid in apply_ids:
continue
_validate_group_id(gid, groups)
apply_ids.append(gid)
rows = _read_entries().to_dicts()
added = 0
for symbol in symbols:
@@ -200,8 +208,9 @@ def add_batch(
added += 1
rows = [row for row in rows if row["symbol"] != symbol]
gids = list((existing or {}).get("group_ids") or [])
if group_id is not None and group_id not in gids:
gids.append(group_id)
for gid in apply_ids:
if gid not in gids:
gids.append(gid)
rows.insert(0, {
"symbol": symbol,
"added_at": datetime.utcnow().isoformat(timespec="seconds"),
+202
View File
@@ -0,0 +1,202 @@
"""自选股 CSV/TXT 与粘贴代码批量导入:解码 → 抽代码 → instruments 校验。
国内行情软件(同花顺/东财/通达信)导出的自选多为 CSV/TXT,且常为 GBK 系编码
(参见 ext_data.ensure_utf8_csv 的说明)。本模块把上传字节 / 粘贴文本解析为与截图
OCR 一致的候选结构,前端复用同一套勾选确认流程;写入目标统一走自选分组语义
watchlist.add_batch 的 group_ids M:N 并入),本模块不落盘、不建标签。
"""
from __future__ import annotations
import csv
import io
import re
from pathlib import Path
from typing import Any
from app.services.watchlist_ocr.pipeline import (
_CODE_RE,
ImportCandidate,
build_instrument_lookups,
extract_codes,
resolve_candidates,
)
_CJK_RE = re.compile(f"[{chr(0x4E00)}-{chr(0x9FFF)}]") # CJK 统一表意文字块
# 编码回退链:UTF-8(含 BOM)→ GB18030GB18030 是 GBK 超集,无需单独回退)
_ENCODINGS = ("utf-8-sig", "gb18030")
def _finalize(
provider: str,
text: str,
codes: list[str],
candidates: list[dict[str, Any]],
) -> dict[str, Any]:
"""组装与截图 OCR 一致的候选响应,统一 matched/unmatched 计数口径。"""
matched_count = sum(1 for c in candidates if c["matched"])
return {
"provider": provider,
"raw_text": text,
"codes": codes,
"candidates": candidates,
"matched_count": matched_count,
"unmatched_count": len(candidates) - matched_count,
}
def decode_csv_bytes(raw: bytes) -> str:
"""把上传字节解码为文本,兼容 UTF-8 / GBK 系编码。"""
if not raw:
raise ValueError("空文件")
last_err: Exception | None = None
for enc in _ENCODINGS:
try:
return raw.decode(enc)
except (UnicodeDecodeError, LookupError) as e:
last_err = e
raise ValueError("无法识别文件编码,请另存为 UTF-8 或 GBK 后重试") from last_err
def _is_code_cell(cell: str) -> bool:
# 调用方(parse_csv_rows)已 strip 过单元格
return bool(_CODE_RE.fullmatch(cell))
def _pick_name(cells: list[str]) -> str | None:
"""取行内首个含 ≥2 个汉字且非六位代码的单元格作为名称候选。"""
for cell in cells:
if not cell or _is_code_cell(cell):
continue
if len(_CJK_RE.findall(cell)) >= 2:
return cell
return None
def parse_csv_rows(text: str) -> list[tuple[list[str], str | None]]:
"""解析 CSV/TXT 文本为 [(行内代码列表, 名称候选), ...]。
- 自动识别逗号 / Tab 分隔(同花顺/通达信导出常见 Tab)。
- 逐行取所有六位数字作为代码候选;无代码行保留给名称兜底(是否输出由
import_watchlist_csv 决定:表头等名称命不中主数据的行会被忽略)。
- 返回列表保持文件行序。
"""
if not text.strip():
return []
first = next((ln for ln in text.splitlines() if ln.strip()), "")
delimiter = "\t" if first.count("\t") > first.count(",") else ","
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
rows: list[tuple[list[str], str | None]] = []
for raw_row in reader:
cells = [c.strip() for c in raw_row if c is not None]
if not cells:
continue
codes: list[str] = []
for cell in cells:
codes.extend(m.group(1) for m in _CODE_RE.finditer(cell))
rows.append((codes, _pick_name(cells)))
return rows
def import_watchlist_csv(
raw: bytes,
data_dir: Path,
*,
existing_symbols: set[str] | None = None,
) -> dict[str, Any]:
"""解析 CSV/TXT 字节并返回候选列表(不写入自选)。返回结构与 OCR 一致。"""
text = decode_csv_bytes(raw)
return _resolve_rows(text, data_dir, existing_symbols=existing_symbols)
def import_watchlist_codes(
text: str,
data_dir: Path,
*,
existing_symbols: set[str] | None = None,
max_codes: int = 1000,
) -> dict[str, Any]:
"""解析粘贴的证券代码并返回候选列表(不写入自选)。
与 CSV 行级解析不同:粘贴文本里的多个代码可能挤在同一行/同一段(逗号、空格、
换行分隔),必须按 ``extract_codes`` 全量抽码、去重保序,逐码生成候选,
否则会把同行多码压成单候选而静默丢码。仅与 CSV 路径共享 lookups/resolve。
"""
codes = extract_codes(text)
if not codes:
return _finalize("codes", text, [], [])
if len(codes) > max_codes:
raise ValueError(f"一次最多导入 {max_codes} 个股票代码,已识别 {len(codes)}")
code_to_symbol, symbol_to_name = build_instrument_lookups(data_dir)
candidates = resolve_candidates(codes, code_to_symbol, symbol_to_name, existing_symbols)
return _finalize("codes", text, codes, [c.to_dict() for c in candidates])
def _resolve_rows(
text: str,
data_dir: Path,
*,
existing_symbols: set[str] | None = None,
) -> dict[str, Any]:
"""逐行把文本解析为候选(CSV/TXT 用;行内多码取首个已匹配者)。"""
rows = parse_csv_rows(text)
code_to_symbol, symbol_to_name = build_instrument_lookups(data_dir)
# 名称兜底反向表:CSV 可能只有名称列(无代码)
name_to_symbol: dict[str, str] = {}
for symbol, name in symbol_to_name.items():
name_to_symbol.setdefault(name, symbol)
existing = existing_symbols or set()
# 全部唯一代码按出现顺序一次性构建候选(复用 OCR 的构造逻辑,单一来源)
unique_codes: list[str] = []
seen_codes: set[str] = set()
for row_codes, _ in rows:
for c in row_codes:
if c not in seen_codes:
seen_codes.add(c)
unique_codes.append(c)
cand_by_code = {
c.code: c
for c in resolve_candidates(unique_codes, code_to_symbol, symbol_to_name, existing)
}
candidates: list[dict[str, Any]] = []
seen_symbols: set[str] = set() # 已发出的已匹配 symbol
emitted_unmatched: set[str] = set() # 已发出的未匹配 code
for row_codes, row_name in rows:
# 行内代码优先:取第一个已匹配主数据的(避免价格/成交量数字误报)
matched = next((cand_by_code[c] for c in row_codes if cand_by_code[c].matched), None)
symbol = matched.symbol if matched else (name_to_symbol.get(row_name) if row_name else None)
if symbol:
if symbol in seen_symbols:
continue
seen_symbols.add(symbol)
cand = matched or ImportCandidate(
code=row_codes[0] if row_codes else "",
symbol=symbol,
name=symbol_to_name.get(symbol),
matched=True,
already_in_watchlist=symbol in existing,
)
else:
# 名称兜底失败且无代码 → 表头/杂项行,忽略
if not row_codes:
continue
code = row_codes[0]
if code in emitted_unmatched:
continue
emitted_unmatched.add(code)
cand = ImportCandidate(
code=code,
symbol=None,
name=row_name,
matched=False,
already_in_watchlist=False,
)
candidates.append(cand.to_dict())
return _finalize("csv", text, unique_codes, candidates)
+377
View File
@@ -0,0 +1,377 @@
"""自选 CSV/TXT 与粘贴代码导入:解码、逐行/整段解析、主数据匹配、API 门禁。
导入落点统一走自选分组语义(watchlist.add_batch 的 group_ids M:N 并入),
本测试同时覆盖该并入语义,作为前端「并入目标分组」交互的服务端依据。
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.api import watchlist as watchlist_api
from app.api.watchlist import ImportCodesRequest, import_from_codes, import_from_csv
from app.config import settings
from app.services import watchlist
from app.services.watchlist_csv import (
decode_csv_bytes,
import_watchlist_codes,
import_watchlist_csv,
parse_csv_rows,
)
def _write_instruments(data_dir: Path) -> None:
inst = data_dir / "instruments"
inst.mkdir()
pl.DataFrame(
{
"code": ["600036", "515880"],
"symbol": ["600036.SH", "515880.SH"],
"name": ["招商银行", "通信ETF国泰"],
}
).write_parquet(inst / "instruments.parquet")
def _mock_upload(*, content: bytes, content_type: str = "text/csv", filename: str = "watchlist.csv") -> MagicMock:
file = MagicMock()
file.content_type = content_type
file.filename = filename
file.read = AsyncMock(return_value=content)
return file
def _mock_request(data_dir: Path) -> MagicMock:
request = MagicMock()
request.app.state.repo.store.data_dir = data_dir
request.app.state.repo.get_name_map = lambda _symbols: {}
return request
# ---- 解码 ----
def test_decode_utf8_bom_stripped():
raw = "代码,名称\n600036,招商银行\n".encode("utf-8-sig")
text = decode_csv_bytes(raw)
assert not text.startswith("")
assert "招商银行" in text
def test_decode_gbk_fallback():
raw = "600519,贵州茅台\n".encode("gbk")
assert "贵州茅台" in decode_csv_bytes(raw)
def test_decode_unknown_encoding_raises():
# 同时破坏 UTF-8 与 GBK 系编码的字节序列
raw = b"\x00\x80\x00\x80\x00\x80"
with pytest.raises(ValueError, match="编码"):
decode_csv_bytes(raw)
def test_decode_empty_raises():
with pytest.raises(ValueError, match="空文件"):
decode_csv_bytes(b"")
# ---- 解析 ----
def test_parse_comma_csv_with_header():
text = "股票代码,股票名称,最新价\n600036,招商银行,42.50\n515880,通信ETF,1.34\n"
rows = parse_csv_rows(text)
# 表头行保留(无代码),由 import 层按名称是否命中主数据过滤
assert rows[0] == ([], "股票代码")
assert [(codes, name) for codes, name in rows[1:]] == [
(["600036"], "招商银行"),
(["515880"], "通信ETF"),
]
def test_parse_tab_separated():
text = "600036\t招商银行\t42.50\n515880\t通信ETF\t1.34\n"
rows = parse_csv_rows(text)
assert [(codes, name) for codes, name in rows] == [
(["600036"], "招商银行"),
(["515880"], "通信ETF"),
]
def test_parse_plain_codes_one_per_line():
text = "600036\n515880\n"
assert [codes for codes, _ in parse_csv_rows(text)] == [["600036"], ["515880"]]
def test_parse_concatenated_code_name_cell():
# 通达信风格:代码+名称在同一字段
text = "600036招商银行\n515880通信ETF国泰\n"
rows = parse_csv_rows(text)
assert [(codes, name) for codes, name in rows] == [
(["600036"], "600036招商银行"),
(["515880"], "515880通信ETF国泰"),
]
# ---- 主数据匹配 / 名称兜底(CSV 行级) ----
def test_import_matched_name_fallback_and_unmatched(tmp_path: Path):
_write_instruments(tmp_path)
raw = (
"代码,名称,现价\n"
"600036,招商银行,42.50\n"
"通信ETF国泰,1.34\n"
"000001,平安银行\n"
"999999,不存在,1.00\n"
).encode()
res = import_watchlist_csv(raw, tmp_path, existing_symbols={"600036.SH"})
by_code = {c["code"]: c for c in res["candidates"]}
assert by_code["600036"]["matched"] and by_code["600036"]["already_in_watchlist"]
assert by_code["600036"]["name"] == "招商银行"
# 名称兜底命中(无代码行)
assert any(
c["symbol"] == "515880.SH" and c["matched"] and c["name"] == "通信ETF国泰"
for c in res["candidates"]
)
assert by_code["000001"]["matched"] is False
assert by_code["999999"]["matched"] is False
assert res["matched_count"] == 2
assert res["unmatched_count"] == 2
assert res["provider"] == "csv"
assert res["codes"] == ["600036", "000001", "999999"]
def test_import_dedupe_and_header_skipped(tmp_path: Path):
_write_instruments(tmp_path)
raw = "代码,名称\n600036,招商银行\n600036,招商银行\n515880,通信ETF国泰\n".encode()
res = import_watchlist_csv(raw, tmp_path)
symbols = [c["symbol"] for c in res["candidates"]]
assert symbols == ["600036.SH", "515880.SH"]
def test_import_junk_rows_ignored(tmp_path: Path):
_write_instruments(tmp_path)
raw = "hello,world\n600036,招商银行\n,,\n".encode()
res = import_watchlist_csv(raw, tmp_path)
assert [c["symbol"] for c in res["candidates"]] == ["600036.SH"]
# ---- 粘贴代码(整段抽码,不压缩同行多码) ----
def test_codes_dedupe_order_and_counts(tmp_path: Path):
_write_instruments(tmp_path)
res = import_watchlist_codes("600036\n515880\n999999\n600036", tmp_path)
assert res["provider"] == "codes"
assert res["codes"] == ["600036", "515880", "999999"]
assert res["matched_count"] == 2
assert res["unmatched_count"] == 1
by_code = {c["code"]: c for c in res["candidates"]}
assert by_code["600036"]["symbol"] == "600036.SH"
assert by_code["999999"]["matched"] is False
def test_codes_same_line_multiple_codes_not_collapsed(tmp_path: Path):
# 同行多码(逗号/空格分隔)不得被压成单候选
_write_instruments(tmp_path)
res = import_watchlist_codes("600036, 515880 601636", tmp_path)
assert res["codes"] == ["600036", "515880", "601636"]
assert res["matched_count"] == 2
assert res["unmatched_count"] == 1
def test_codes_blank_returns_empty_without_parquet(tmp_path: Path):
# 无码时不读主数据直接返回空结果(数据目录甚至不存在)
res = import_watchlist_codes(" \n ", tmp_path)
assert res["codes"] == []
assert res["candidates"] == []
assert res["matched_count"] == 0 and res["unmatched_count"] == 0
def test_codes_over_max_raises(tmp_path: Path):
text = "\n".join(f"{600000 + i}" for i in range(1001))
with pytest.raises(ValueError, match="最多"):
import_watchlist_codes(text, tmp_path, max_codes=1000)
def test_import_functions_do_not_write_watchlist(tmp_path: Path, monkeypatch):
_write_instruments(tmp_path)
monkeypatch.setattr(settings, "data_dir", tmp_path)
before = watchlist.list_symbols()
assert before == []
import_watchlist_csv("600036,招商银行\n".encode(), tmp_path)
import_watchlist_codes("515880", tmp_path)
assert watchlist.list_symbols() == []
# ---- 分组合并语义(导入落点,M:N 并入服务端依据) ----
def test_add_batch_merges_into_target_group_mn(tmp_path: Path, monkeypatch):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, g1 = watchlist.create_group("组合一")
_, g2 = watchlist.create_group("组合二")
watchlist.add("600036.SH", group_id=g1["id"])
rows, added = watchlist.add_batch(["600036.SH", "515880.SH"], group_id=g2["id"])
by_symbol = {r["symbol"]: r for r in rows}
# 已在自选且属组合一:并入组合二,两组共存(M:N)
assert g1["id"] in by_symbol["600036.SH"]["group_ids"]
assert g2["id"] in by_symbol["600036.SH"]["group_ids"]
# 真正新增的才计 added
assert g2["id"] in by_symbol["515880.SH"]["group_ids"]
assert added == 1
def test_add_batch_merges_into_multiple_groups_mn(tmp_path: Path, monkeypatch):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, g1 = watchlist.create_group("组合一")
_, g2 = watchlist.create_group("组合二")
_, g3 = watchlist.create_group("组合三")
watchlist.add("600036.SH", group_id=g1["id"])
# 一次批量调用同时并入 g2/g3(多组导入,原子单写;含重复 id 验证去重)
rows, added = watchlist.add_batch(
["600036.SH", "515880.SH"],
group_ids=[g2["id"], g3["id"], g2["id"]],
)
by_symbol = {r["symbol"]: r for r in rows}
gids600 = by_symbol["600036.SH"]["group_ids"]
# 保留原有 g1 且并入 g2/g3
assert sorted(gids600) == sorted([g1["id"], g2["id"], g3["id"]])
assert sorted(by_symbol["515880.SH"]["group_ids"]) == sorted([g2["id"], g3["id"]])
assert added == 1
# ---- API 门禁:CSV ----
@pytest.mark.asyncio
async def test_import_csv_rejects_bad_extension(tmp_path: Path):
request = _mock_request(tmp_path)
# 类型与扩展名都非白名单 → 拒绝
file = _mock_upload(
content=b"x",
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="watchlist.xlsx",
)
with pytest.raises(HTTPException) as ei:
await import_from_csv(request, file)
assert ei.value.status_code == 400
assert "仅支持" in str(ei.value.detail)
@pytest.mark.asyncio
async def test_import_csv_rejects_oversized_bytes(tmp_path: Path):
request = _mock_request(tmp_path)
file = _mock_upload(content=b"x" * (5 * 1024 * 1024 + 1))
with pytest.raises(HTTPException) as ei:
await import_from_csv(request, file)
assert ei.value.status_code == 400
assert "过大" in str(ei.value.detail)
@pytest.mark.asyncio
async def test_import_csv_rejects_empty(tmp_path: Path):
request = _mock_request(tmp_path)
file = _mock_upload(content=b"")
with pytest.raises(HTTPException) as ei:
await import_from_csv(request, file)
assert ei.value.status_code == 400
assert "空文件" in str(ei.value.detail)
@pytest.mark.asyncio
async def test_import_csv_no_candidates_raises(tmp_path: Path, monkeypatch):
_write_instruments(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
request = _mock_request(tmp_path)
file = _mock_upload(content=b"hello\nworld\n")
with pytest.raises(HTTPException) as ei:
await import_from_csv(request, file)
assert ei.value.status_code == 400
assert "未识别" in str(ei.value.detail)
@pytest.mark.asyncio
async def test_import_csv_maps_value_error_to_400(tmp_path: Path, monkeypatch):
request = _mock_request(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
def boom(*_a, **_k):
raise ValueError("无法识别文件编码")
monkeypatch.setattr(watchlist_api, "import_watchlist_csv", boom)
file = _mock_upload(content=b"x")
with pytest.raises(HTTPException) as ei:
await import_from_csv(request, file)
assert ei.value.status_code == 400
assert "编码" in str(ei.value.detail)
@pytest.mark.asyncio
async def test_import_csv_success(tmp_path: Path, monkeypatch):
_write_instruments(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
request = _mock_request(tmp_path)
file = _mock_upload(content="代码,名称\n600036,招商银行\n515880,通信ETF国泰\n".encode())
res = await import_from_csv(request, file)
assert res["provider"] == "csv"
assert res["matched_count"] == 2
assert {c["symbol"] for c in res["candidates"]} == {"600036.SH", "515880.SH"}
assert "raw_text" not in res
# ---- API 门禁:粘贴代码 ----
def test_import_codes_rejects_empty(tmp_path: Path, monkeypatch):
request = _mock_request(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
with pytest.raises(HTTPException) as ei:
import_from_codes(ImportCodesRequest(text=" "), request)
assert ei.value.status_code == 400
assert "请输入" in str(ei.value.detail)
def test_import_codes_no_codes_raises(tmp_path: Path, monkeypatch):
request = _mock_request(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
with pytest.raises(HTTPException) as ei:
import_from_codes(ImportCodesRequest(text="hello world"), request)
assert ei.value.status_code == 400
assert "未识别" in str(ei.value.detail)
def test_import_codes_over_max_raises(tmp_path: Path, monkeypatch):
request = _mock_request(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
text = "\n".join(f"{600000 + i}" for i in range(1001))
with pytest.raises(HTTPException) as ei:
import_from_codes(ImportCodesRequest(text=text), request)
assert ei.value.status_code == 400
assert "最多" in str(ei.value.detail)
def test_import_codes_maps_value_error_to_400(tmp_path: Path, monkeypatch):
request = _mock_request(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
def boom(*_a, **_k):
raise ValueError("一次最多导入 1000 个")
monkeypatch.setattr(watchlist_api, "import_watchlist_codes", boom)
with pytest.raises(HTTPException) as ei:
import_from_codes(ImportCodesRequest(text="600036"), request)
assert ei.value.status_code == 400
assert "最多" in str(ei.value.detail)
def test_import_codes_success(tmp_path: Path, monkeypatch):
_write_instruments(tmp_path)
monkeypatch.setattr(watchlist, "list_symbols", lambda: [])
request = _mock_request(tmp_path)
res = import_from_codes(ImportCodesRequest(text="600036\n515880\n999999"), request)
assert res["provider"] == "codes"
assert res["matched_count"] == 2
assert res["unmatched_count"] == 1
assert {c["symbol"] for c in res["candidates"] if c["matched"]} == {"600036.SH", "515880.SH"}
assert "raw_text" not in res
+578 -238
View File
@@ -1,28 +1,67 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ImagePlus, Loader2, Upload, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { FileText, ImagePlus, Keyboard, Loader2, Plus, Upload, X } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { Modal } from '@/components/Modal'
import { toast } from '@/components/Toast'
import { api, type WatchlistGroupColor, type WatchlistImportCandidate } from '@/lib/api'
import { api, type WatchlistGroup, type WatchlistGroupColor, type WatchlistImportCandidate, type WatchlistImportResult } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import {
DEFAULT_WATCHLIST_GROUP_COLOR,
WATCHLIST_GROUP_COLORS,
resolveWatchlistGroupColor,
} from '@/lib/watchlist-group-colors'
interface Props {
open: boolean
onClose: () => void
/** 页面当前所在分组,作为默认目标分组(null=未分组)。 */
groupId?: string | null
groupName?: string
groupColor?: WatchlistGroupColor
/** 分组列表与成员快照由页面持有并下发,避免弹窗重复拉取。 */
groups: WatchlistGroup[]
existingBySymbol: ReadonlyMap<string, string[]>
}
/** 一次最多排队识别的图片数,避免误选大量文件拖垮小内存机器。 */
const MAX_IMPORT_IMAGES = 10
const NO_MATCH_MSG = '未能匹配证券主数据,已跳过'
const DROP_ACCEPT =
'image/jpeg,image/png,image/webp,image/bmp,image/gif,text/csv,text/plain,' +
'.csv,.txt,.jpg,.jpeg,.png,.webp,.bmp,.gif'
interface RowState {
eligible: boolean
inWatchlist: boolean
inAllSelected: boolean
}
/**
* 未分组目标(空数组)只收新增;选定分组时,同时属于全部所选分组的标的不再可加。
* 其余已匹配标的可勾选(并入尚未属于的分组)。
*/
function rowState(
sym: string | null,
matched: boolean,
membership: ReadonlyMap<string, string[]>,
targetIds: string[],
): RowState {
if (!matched || !sym) return { eligible: false, inWatchlist: false, inAllSelected: false }
const gids = membership.get(sym)
const inWatchlist = gids !== undefined
const inAllSelected = targetIds.length > 0
&& gids !== undefined && targetIds.every(gid => gids.includes(gid))
const eligible = targetIds.length === 0 ? !inWatchlist : !inAllSelected
return { eligible, inWatchlist, inAllSelected }
}
function isImageFile(file: File): boolean {
return file.type.startsWith('image/') || /\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name)
}
/** 按 code 合并多图 OCR 结果:优先保留已匹配项,已在自选取并集。 */
function isCsvFile(file: File): boolean {
return /\.(csv|txt)$/i.test(file.name)
}
/** 多来源候选(多图 / 截图+文件混搭)按 code 合并,保留已匹配项。 */
export function mergeImportCandidates(
lists: WatchlistImportCandidate[][],
): WatchlistImportCandidate[] {
@@ -51,20 +90,102 @@ export function mergeImportCandidates(
return [...byCode.values()]
}
export function WatchlistImportDialog({ open, onClose, groupId, groupName, groupColor }: Props) {
type Row = { c: WatchlistImportCandidate; state: RowState }
function Dropzone({
busy,
label,
onPick,
}: {
busy: boolean
label: string
onPick: (list: FileList | File[] | null | undefined) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<>
<input
ref={inputRef}
type="file"
multiple
accept={DROP_ACCEPT}
className="hidden"
onChange={e => {
onPick(e.target.files)
e.target.value = ''
}}
/>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
onDrop={e => {
e.preventDefault()
onPick(e.dataTransfer.files)
}}
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 ? label : '点击选择或拖拽券商自选截图 / CSV / TXT'}</span>
</button>
</>
)
}
export function WatchlistImportDialog({
open,
onClose,
groupId,
groups,
existingBySymbol,
}: Props) {
const abortRef = useRef<AbortController | null>(null)
const genRef = useRef(0)
const qc = useQueryClient()
const [busy, setBusy] = useState(false)
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null)
const [provider, setProvider] = useState<string>('')
const [candidates, setCandidates] = useState<WatchlistImportCandidate[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
const [installHint, setInstallHint] = useState('')
const [sourceFile, setSourceFile] = useState('')
const [pasteOpen, setPasteOpen] = useState(false)
const [codesText, setCodesText] = useState('')
const [showSkipped, setShowSkipped] = useState(false)
const [targetGroupIds, setTargetGroupIds] = useState<string[]>([])
const [newGroupOpen, setNewGroupOpen] = useState(false)
const [newGroupName, setNewGroupName] = useState('')
const [newGroupColor, setNewGroupColor] = useState<WatchlistGroupColor>(DEFAULT_WATCHLIST_GROUP_COLOR)
const [creatingGroup, setCreatingGroup] = useState(false)
const batchAdd = useWatchlistBatchAdd()
const membership = existingBySymbol
const groupNameById = useMemo(() => {
const m = new Map<string, string>()
for (const g of groups) m.set(g.id, g.name)
return m
}, [groups])
const { eligible, skipped } = useMemo(() => {
const eligible: Row[] = []
const skipped: Row[] = []
for (const c of candidates) {
const state = rowState(c.symbol, c.matched, membership, targetGroupIds)
;(state.eligible ? eligible : skipped).push({ c, state })
}
return { eligible, skipped }
}, [candidates, membership, targetGroupIds])
const skippedCount = skipped.length
const matchedCount = useMemo(
() => candidates.filter(c => c.matched && c.symbol).length,
[candidates],
)
const abortInFlight = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
@@ -81,14 +202,17 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
setProgress(null)
setCandidates([])
setSelected(new Set())
setProvider('')
setOcrAvailable(null)
setInstallHint('')
setPreviewUrls(prev => {
revokePreviews(prev)
return []
})
if (inputRef.current) inputRef.current.value = ''
setSourceFile('')
setPasteOpen(false)
setCodesText('')
setShowSkipped(false)
setNewGroupOpen(false)
setNewGroupName('')
setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR)
}, [abortInFlight, revokePreviews])
useEffect(() => {
@@ -96,100 +220,99 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
reset()
return
}
let cancelled = false
void api.watchlistOcrStatus().then(
res => {
if (cancelled) return
setOcrAvailable(res.available)
if (!res.available) setInstallHint(getOcrInstallHint())
},
() => {
if (cancelled) return
setOcrAvailable(false)
setInstallHint(getOcrInstallHint())
},
)
return () => {
cancelled = true
}
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
setTargetGroupIds(groupId ? [groupId] : [])
}, [open, groupId, reset])
const runRecognizeQueue = async (files: File[]) => {
const images = files.filter(isImageFile)
if (images.length === 0) {
toast('请选择图片文件', 'error')
return
}
if (images.length < files.length) {
toast('已忽略非图片文件', 'error')
}
const queue = images.slice(0, MAX_IMPORT_IMAGES)
if (images.length > MAX_IMPORT_IMAGES) {
toast(`一次最多识别 ${MAX_IMPORT_IMAGES} 张,已取前 ${MAX_IMPORT_IMAGES}`, 'error')
const defaultSelection = (list: WatchlistImportCandidate[]) => {
const out = new Set<string>()
for (const c of list) {
if (c.matched && c.symbol && !membership.has(c.symbol)) out.add(c.symbol)
}
return out
}
/** 切目标分组后,只摘掉不再可选的已勾标的(新增默认勾选在解析时一次性设定)。 */
const changeTargetGroups = (next: string[]) => {
setTargetGroupIds(next)
const eligibleNow = new Set<string>()
for (const c of candidates) {
const sym = c.symbol
if (sym && rowState(sym, c.matched, membership, next).eligible) eligibleNow.add(sym)
}
setSelected(prev => {
let changed = false
const kept = new Set<string>()
for (const sym of prev) {
if (eligibleNow.has(sym)) kept.add(sym)
else changed = true
}
return changed ? kept : prev
})
}
const stage = async (run: (signal: AbortSignal) => Promise<WatchlistImportResult>) => {
abortInFlight()
const controller = new AbortController()
abortRef.current = controller
const gen = genRef.current
setBusy(true)
try {
const res = await run(controller.signal)
if (gen !== genRef.current) return
setCandidates(res.candidates)
setSelected(defaultSelection(res.candidates))
if (res.candidates.length > 0 && res.matched_count === 0) toast(NO_MATCH_MSG, 'error')
} catch {
/* 请求错误已由 request 封装弹出 */
} finally {
if (gen === genRef.current) setBusy(false)
}
}
const runRecognizeQueue = async (files: File[]) => {
const images = files.filter(isImageFile)
const queue = images.slice(0, MAX_IMPORT_IMAGES)
if (images.length > MAX_IMPORT_IMAGES) {
toast(`一次最多识别 ${MAX_IMPORT_IMAGES} 张,已取前 ${MAX_IMPORT_IMAGES}`, 'error')
}
setPreviewUrls(prev => {
revokePreviews(prev)
return queue.map(f => URL.createObjectURL(f))
})
setBusy(true)
setProgress({ done: 0, total: queue.length })
setCandidates([])
setSelected(new Set())
setProvider('')
const mergedLists: WatchlistImportCandidate[][] = []
let lastProvider = ''
setSourceFile('')
setShowSkipped(false)
const controller = new AbortController()
abortRef.current = controller
const gen = genRef.current
const merged: WatchlistImportCandidate[][] = []
let failed = 0
let lastError = ''
setProgress({ done: 0, total: queue.length })
setBusy(true)
try {
for (let i = 0; i < queue.length; i++) {
if (gen !== genRef.current || controller.signal.aborted) return
try {
// quiet:避免每张失败各弹一条 toast,结束时统一提示
const res = await api.watchlistImportImage(queue[i], controller.signal, true)
if (gen !== genRef.current) return
lastProvider = res.provider
mergedLists.push(res.candidates)
merged.push(res.candidates)
} catch (err) {
if (gen !== genRef.current) return
if (controller.signal.aborted) return
if (gen !== genRef.current || controller.signal.aborted) return
failed += 1
lastError = err instanceof Error ? err.message : ''
}
if (gen === genRef.current) {
setProgress({ done: i + 1, total: queue.length })
}
setProgress({ done: i + 1, total: queue.length })
}
if (gen !== genRef.current) return
const merged = mergeImportCandidates(mergedLists)
setProvider(lastProvider)
setCandidates(merged)
const defaults = new Set(
merged
.filter(c => c.matched && c.symbol && !c.already_in_watchlist)
.map(c => c.symbol!),
)
setSelected(defaults)
if (merged.length === 0) {
const all = mergeImportCandidates(merged)
setCandidates(all)
setSelected(defaultSelection(all))
if (all.length === 0) {
toast(
lastError
|| (failed > 0
? '识别失败或未识别到股票代码,请换更清晰的截图'
: '未识别到股票代码,请换一张更清晰的自选列表截图'),
|| (failed > 0 ? '识别失败或未识别到股票代码' : '未识别到股票代码,请换更清晰的截图'),
'error',
)
} else if (merged.every(c => !c.matched)) {
toast('识别到代码但未能匹配证券主数据', 'error')
} else if (all.every(c => !c.matched)) {
toast(NO_MATCH_MSG, 'error')
} else if (failed > 0) {
toast(`${failed} 张识别失败,已合并其余结果`, 'error')
}
@@ -201,9 +324,69 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
}
}
const onPick = (list: FileList | File[] | null | undefined) => {
const runCsvImport = async (file: File) => {
setSourceFile(file.name)
setPreviewUrls(prev => { revokePreviews(prev); return [] })
setShowSkipped(false)
await stage(signal => api.watchlistImportCsv(file, signal))
}
const runCodesParse = async () => {
setSourceFile('')
setShowSkipped(false)
await stage(signal => api.watchlistImportCodes(codesText.trim(), signal))
}
const onSourcePick = async (list: FileList | File[] | null | undefined) => {
if (!list || list.length === 0) return
void runRecognizeQueue(Array.from(list))
const files = Array.from(list)
const images = files.filter(isImageFile)
const csvs = files.filter(isCsvFile)
if (csvs.length > 0) {
if (csvs.length > 1 || images.length > 0) {
toast('截图与 CSV 请分别导入', 'error')
return
}
await runCsvImport(csvs[0])
return
}
if (images.length > 0) {
if (images.length < files.length) toast('已忽略非截图文件', 'error')
await runRecognizeQueue(images)
return
}
toast('请选择券商自选截图或 CSV / TXT 文件', 'error')
}
const runCodes = async () => {
if (!codesText.trim()) {
toast('请粘贴或输入股票代码', 'error')
return
}
await runCodesParse()
}
const createGroup = async () => {
const name = newGroupName.trim()
if (!name) return
setCreatingGroup(true)
try {
const data = await api.watchlistGroupCreate(name, newGroupColor)
// 服务端返回全量分组列表;侧栏/分组条与本弹窗共用 QK.watchlistGroups 缓存
qc.setQueryData(QK.watchlistGroups, { groups: data.groups })
changeTargetGroups(
targetGroupIds.includes(data.group.id)
? targetGroupIds
: [...targetGroupIds, data.group.id],
)
setNewGroupOpen(false)
setNewGroupName('')
setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR)
} catch {
/* 已由 request 弹出 */
} finally {
setCreatingGroup(false)
}
}
const toggle = (symbol: string) => {
@@ -215,13 +398,23 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
})
}
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!))
let allSelected = eligible.length > 0
for (const row of eligible) {
if (!selected.has(row.c.symbol!)) {
allSelected = false
break
}
}
const toggleAll = () => {
if (allSelected) setSelected(new Set())
else setSelected(new Set(selectable.map(c => c.symbol!)))
setSelected(prev => {
const next = new Set(prev)
for (const row of eligible) {
if (allSelected) next.delete(row.c.symbol!)
else next.add(row.c.symbol!)
}
return next
})
}
const confirmAdd = async () => {
@@ -230,25 +423,84 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
toast('请至少选择一只股票', 'error')
return
}
const newCount = symbols.filter(sym => !membership.has(sym)).length
const mergedCount = symbols.length - newCount
try {
const data = await batchAdd.mutateAsync({ symbols, groupId })
toast(`已添加 ${data.added} 只自选`, 'success')
await batchAdd.mutateAsync({ symbols, groupIds: targetGroupIds })
const names = targetGroupIds
.map(id => groupNameById.get(id))
.filter((n): n is string => !!n)
.join('、')
if (names) {
toast(
mergedCount > 0
? `已导入 ${symbols.length} 只到「${names}」(新增 ${newCount},并入 ${mergedCount}`
: `已导入 ${newCount} 只到「${names}`,
'success',
)
} else {
toast(`已添加 ${newCount} 只自选`, 'success')
}
onClose()
} catch {
/* toast in request */
/* 已由 request 弹出 */
}
}
if (!open) return null
const ocrBlocked = ocrAvailable === false
const progressLabel =
progress && progress.total > 1
? `识别中 ${progress.done}/${progress.total}`
: progress
? '识别中…'
: null
const selectedGroupColor = resolveWatchlistGroupColor(groupColor)
const renderRow = ({ c, state }: Row) => {
const sym = c.symbol
const checked = !!sym && selected.has(sym)
let status: ReactNode = null
if (!c.matched || !sym) {
status = <span className="text-[10px] text-warning/90">{NO_MATCH_MSG}</span>
} else if (state.inAllSelected) {
status = <span className="text-[10px] text-muted"></span>
} else if (state.inWatchlist) {
status = (
<span className="text-[10px] text-muted">
{targetGroupIds.length > 0 ? '已在自选 · 将并入所选分组' : '已在自选'}
</span>
)
}
return (
<li key={sym ?? c.code}>
<label
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
state.eligible ? 'cursor-pointer hover:bg-elevated/50' : 'opacity-50 cursor-not-allowed'
}`}
>
<input
type="checkbox"
disabled={!state.eligible}
checked={checked}
onChange={() => sym && toggle(sym)}
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 && sym ? sym : '未匹配')}
</span>
<span className="text-[11px] text-muted tabular-nums shrink-0">
{c.code}
{sym ? ` · ${sym}` : ''}
</span>
</div>
{status}
</div>
</label>
</li>
)
}
return (
<Modal
@@ -259,22 +511,11 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
<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>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<p className="text-[11px] text-muted">
{ocrBlocked
? 'OCR 引擎不可用'
: '可多选截图,将逐张识别并合并结果后确认添加'}
{provider ? ` · ${provider}` : ''}
</p>
{groupName && (
<span className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${selectedGroupColor.text} ${selectedGroupColor.border} ${selectedGroupColor.background}`}>
<span className={`h-1.5 w-1.5 rounded-full ${selectedGroupColor.dot}`} />
{groupName}
</span>
)}
</div>
<p className="text-[11px] text-muted mt-0.5">
/ CSV / TXT /
</p>
</div>
<button
type="button"
@@ -287,147 +528,246 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
</div>
<div className="px-4 py-3 overflow-y-auto flex-1 space-y-3">
{ocrBlocked ? (
<div className="rounded-btn border border-border bg-elevated/40 px-4 py-5 text-xs text-secondary leading-relaxed whitespace-pre-wrap">
{installHint}
</div>
) : (
<>
<input
ref={inputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/bmp,image/gif,.jpg,.jpeg,.png"
className="hidden"
onChange={e => {
onPick(e.target.files)
e.target.value = ''
}}
/>
{busy && progressLabel && (
<p className="text-[11px] text-muted">{progressLabel}</p>
)}
<div className="space-y-2">
<Dropzone busy={busy} label={progressLabel ?? '解析中…'} onPick={(l) => void onSourcePick(l)} />
{!pasteOpen ? (
<button
type="button"
disabled={busy || ocrAvailable === null}
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
onDrop={e => {
e.preventDefault()
onPick(e.dataTransfer.files)
}}
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"
onClick={() => setPasteOpen(true)}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-btn border border-dashed border-border bg-elevated/40 px-3 py-2 text-xs text-secondary hover:bg-elevated/70"
>
{busy || ocrAvailable === null ? (
<Loader2 className="h-6 w-6 animate-spin text-accent" />
) : (
<ImagePlus className="h-6 w-6 text-accent" />
)}
<span className="text-xs">
{progressLabel
?? (ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图(支持多选)')}
</span>
<Keyboard className="h-3.5 w-3.5 text-accent" />
</button>
{previewUrls.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{previewUrls.map((url, i) => (
<div
key={url}
className="shrink-0 w-20 h-20 rounded-btn overflow-hidden border border-border bg-black/40"
) : (
<div className="space-y-2 rounded-btn border border-border bg-elevated/40 p-2.5">
<textarea
autoFocus
value={codesText}
onChange={e => setCodesText(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runCodes()
}}
placeholder={'示例:\n600519\n000001 平安银行\n515880 通信ETF国泰'}
rows={4}
className="w-full resize-y rounded-btn border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-muted focus:border-accent/50 focus:outline-none"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"> / / Ctrl/+Enter </span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setPasteOpen(false); setCodesText('') }}
className="h-7 px-2 rounded-btn text-[11px] text-secondary hover:bg-elevated"
>
<img
src={url}
alt={`预览 ${i + 1}`}
className="w-full h-full object-contain"
/>
</div>
))}
</button>
<button
type="button"
disabled={busy || !codesText.trim()}
onClick={() => void runCodes()}
className="h-7 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Keyboard className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
)}
</div>
{previewUrls.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{previewUrls.map((url, i) => (
<div
key={url}
className="shrink-0 w-16 h-16 rounded-btn overflow-hidden border border-border bg-black/40"
>
<img src={url} alt={`预览 ${i + 1}`} className="w-full h-full object-contain" />
</div>
))}
</div>
)}
{sourceFile && (
<div className="flex items-center gap-2 rounded-btn border border-border bg-elevated/40 px-3 py-2 text-xs text-secondary">
<FileText className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate flex-1">{sourceFile}</span>
</div>
)}
{candidates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-secondary">
{candidates.length} · {matchedCount} · {eligible.length}
</span>
{eligible.length > 0 ? (
<button
type="button"
onClick={toggleAll}
className="text-[11px] text-accent hover:underline shrink-0"
>
{allSelected ? '取消全选' : '全选可添加'}
</button>
) : matchedCount > 0 ? (
<span className="text-[11px] text-muted shrink-0"></span>
) : null}
</div>
{skippedCount > 0 && (
<button
type="button"
onClick={() => setShowSkipped(v => !v)}
className="flex items-center gap-1 text-[11px] text-muted hover:text-secondary"
>
<span className="transition-transform" style={{ transform: showSkipped ? 'rotate(90deg)' : undefined }}></span>
{skippedCount} /
</button>
)}
{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 || c.already_in_watchlist
const checked = !!(c.symbol && selected.has(c.symbol))
return (
<li key={key}>
<label
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-elevated/50'
}`}
>
<input
type="checkbox"
disabled={disabled}
checked={checked}
onChange={() => c.symbol && toggle(c.symbol)}
className="rounded border-border"
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-foreground truncate">
{c.name || (c.matched ? c.symbol : '未匹配')}
</span>
<span className="text-[11px] text-muted tabular-nums shrink-0">
{c.code}
{c.symbol ? ` · ${c.symbol}` : ''}
</span>
</div>
{c.already_in_watchlist && (
<span className="text-[10px] text-muted"></span>
)}
{!c.matched && (
<span className="text-[10px] text-warning/90"></span>
)}
</div>
</label>
</li>
)
})}
</ul>
</div>
)}
</>
<ul className="divide-y divide-border/60 rounded-btn border border-border overflow-hidden">
{eligible.map(renderRow)}
{showSkipped && skipped.map(renderRow)}
</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={ocrBlocked || selected.size === 0 || batchAdd.isPending || busy}
onClick={() => void confirmAdd()}
className="h-8 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
>
{batchAdd.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
<div className="px-4 py-3 border-t border-border shrink-0 space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-start gap-2">
<span className="text-[11px] text-secondary pt-1.5 shrink-0"></span>
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<button
type="button"
onClick={() => changeTargetGroups([])}
aria-pressed={targetGroupIds.length === 0}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] transition-colors ${
targetGroupIds.length === 0
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-elevated text-secondary hover:text-foreground'
}`}
title="不加到任何分组,仅新增标的到自选"
>
</button>
<span className="mx-1 h-3 w-px shrink-0 self-center bg-border/60" aria-hidden="true" />
{groups.map(g => {
const active = targetGroupIds.includes(g.id)
const c = resolveWatchlistGroupColor(g.color)
return (
<button
key={g.id}
type="button"
onClick={() => changeTargetGroups(
active
? targetGroupIds.filter(id => id !== g.id)
: [...targetGroupIds, g.id],
)}
aria-pressed={active}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] transition-colors ${
active
? `${c.border} ${c.background} ${c.text}`
: 'border-border bg-elevated text-secondary hover:bg-elevated/80 hover:text-foreground'
}`}
title={`${active ? '移出' : '加入'}目标分组「${g.name}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${active ? c.dot : 'bg-border'}`} />
{g.name}
</button>
)
})}
{!newGroupOpen && (
<button
type="button"
onClick={() => setNewGroupOpen(true)}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border bg-elevated/40 px-2 py-1 text-[11px] text-accent hover:bg-elevated/70"
title="新建分组接收这批导入"
>
<Plus className="h-3 w-3" />
</button>
)}
</div>
</div>
{newGroupOpen && (
<div className="rounded-btn border border-border bg-elevated/40 p-2 space-y-2">
<div className="flex items-center gap-1.5">
<input
autoFocus
maxLength={24}
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void createGroup()
if (e.key === 'Escape') { setNewGroupOpen(false); setNewGroupName(''); setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR) }
}}
placeholder="新分组名称,Enter 创建"
className="h-8 min-w-0 flex-1 rounded-btn border border-border bg-surface px-2 text-xs text-foreground placeholder:text-muted focus:border-accent/50 focus:outline-none"
aria-label="新分组名称"
/>
<button
type="button"
onClick={() => void createGroup()}
disabled={creatingGroup || !newGroupName.trim()}
title="创建分组,并作为本次导入的目标分组"
className="h-8 shrink-0 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
>
{creatingGroup ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
<div className="flex items-center gap-2 pl-1">
<span className="text-[11px] text-secondary shrink-0"></span>
<div className="flex flex-wrap items-center gap-1.5">
{WATCHLIST_GROUP_COLORS.map(option => {
const active = option.id === newGroupColor
return (
<button
key={option.id}
type="button"
onClick={() => setNewGroupColor(option.id)}
aria-pressed={active}
aria-label={`颜色 ${option.label}`}
title={option.label}
className={`h-4 w-4 rounded-full transition-transform ${option.dot} ${active ? `ring-2 ${option.ring} scale-110` : 'hover:scale-110'}`}
/>
)
})}
</div>
</div>
</div>
)}
({selected.size})
</button>
</div>
<div className="flex items-center justify-end gap-2">
<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>
</div>
</Modal>
)
+23 -2
View File
@@ -2192,10 +2192,15 @@ export const api = {
method: 'POST',
body: JSON.stringify({ symbol, note, group_id: groupId ?? null }),
}),
watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null) =>
watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null, groupIds?: string[]) =>
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
method: 'POST',
body: JSON.stringify({ symbols, note, group_id: groupId ?? null }),
body: JSON.stringify({
symbols,
note,
group_id: groupId ?? null,
group_ids: groupIds?.length ? groupIds : null,
}),
}),
watchlistGroups: () =>
request<{ groups: WatchlistGroup[] }>('/api/watchlist/groups'),
@@ -2251,6 +2256,22 @@ export const api = {
quiet,
})
},
watchlistImportCsv: (file: File, signal?: AbortSignal, quiet = false) => {
const fd = new FormData()
fd.append('file', file)
return request<WatchlistImportResult>('/api/watchlist/import-csv', {
method: 'POST',
body: fd,
signal,
quiet,
})
},
watchlistImportCodes: (text: string, signal?: AbortSignal) =>
request<WatchlistImportResult>('/api/watchlist/import-codes', {
method: 'POST',
body: JSON.stringify({ text }),
signal,
}),
watchlistRemove: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}`,
+3 -2
View File
@@ -32,14 +32,15 @@ export function useUpdateQuoteInterval() {
interface WatchlistBatchAddInput {
symbols: string[]
groupId?: string | null
groupIds?: string[]
}
/** 批量添加自选 — Screener / 截图导入共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ symbols, groupId }: WatchlistBatchAddInput) =>
api.watchlistBatchAdd(symbols, '', groupId),
mutationFn: ({ symbols, groupId, groupIds }: WatchlistBatchAddInput) =>
api.watchlistBatchAdd(symbols, '', groupId, groupIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
// 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam],
+10 -42
View File
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, FileUp, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
import { QK } from '@/lib/queryKeys'
@@ -32,7 +32,6 @@ import { ExtensionSlot } from '@/extensions/ExtensionSlot'
// 分时列开放排序 (StockDataTable 实例级白名单; 表头眼睛/刷新按钮已 stopPropagation)
const INTRADAY_SORTABLE_KEYS = new Set(['intraday'])
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable'
import { VIRTUAL_LIST_THRESHOLD, useParentScroll } from '@/components/virtual-list/useParentScroll'
@@ -681,8 +680,6 @@ export function Watchlist() {
const g = (searchParams.get('group') as WatchlistGroupFilter | null) ?? 'all'
setSelectedGroup(g)
}, [searchParams])
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
const [ocrInstallHint, setOcrInstallHint] = useState('')
const columnsLoaded = useRef(false)
useEffect(() => {
@@ -691,25 +688,6 @@ export function Watchlist() {
loadColumnConfig().then(setColumns)
}, [])
useEffect(() => {
let cancelled = false
void api.watchlistOcrStatus().then(
res => {
if (cancelled) return
setOcrAvailable(res.available)
if (!res.available) setOcrInstallHint(getOcrInstallHint())
},
() => {
if (cancelled) return
setOcrAvailable(false)
setOcrInstallHint(getOcrInstallHint())
},
)
return () => {
cancelled = true
}
}, [])
const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
setColumns(next)
saveColumnConfig(next)
@@ -1115,9 +1093,6 @@ export function Watchlist() {
if (selectedGroup === 'ungrouped') return rowsWithGroup.filter(row => row.group_ids.length === 0)
return rowsWithGroup.filter(row => row.group_ids.includes(selectedGroup))
}, [groupBySymbol, rows, selectedGroup])
const activeGroup = activeGroupId
? groups.find(group => group.id === activeGroupId)
: undefined
const watchlistContentLoading = list.isLoading || (allSymbols.length > 0 && enriched.isLoading)
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
@@ -1432,19 +1407,12 @@ export function Watchlist() {
memberPending={addGroupMember.isPending || removeGroupMember.isPending}
/>
<button
onClick={() => {
if (ocrAvailable === false) return
setImportOpen(true)
}}
disabled={ocrAvailable === false}
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-elevated disabled:hover:text-secondary"
title={
ocrAvailable === false
? ocrInstallHint || 'OCR 不可用,请先安装 Tesseract'
: '从截图导入自选'
}
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="批量导入自选(截图 / CSV / 粘贴代码)"
aria-label="批量导入自选"
>
<ImagePlus className="h-4 w-4" />
<FileUp className="h-4 w-4" />
</button>
<div className="w-px h-5 bg-border" />
{/* 视图 */}
@@ -1655,13 +1623,13 @@ export function Watchlist() {
<EmptyState
icon={Star}
title="自选股为空"
hint="点击右上角搜索添加标的,或点击图片图标从券商自选截图批量导入。"
hint="点击右上角搜索添加标的,或用导入按钮从券商自选 CSV / 截图批量导入、粘贴代码。"
/>
) : rowsInSelectedGroup.length === 0 ? (
<EmptyState
icon={FolderOpen}
title="该分组暂无标的"
hint="使用右上角搜索添加,或通过股票旁的分组按钮移入当前分组。"
hint="使用右上角搜索添加通过股票旁的分组按钮移入,或用导入弹窗把整批标的并入本组。"
/>
) : groupCardsOpen ? (
<WatchlistGroupCards
@@ -2025,8 +1993,8 @@ export function Watchlist() {
open={importOpen}
onClose={() => setImportOpen(false)}
groupId={activeGroupId}
groupName={activeGroup?.name}
groupColor={activeGroup?.color}
groups={groups}
existingBySymbol={groupBySymbol}
/>
</div>
)