feat: add watchlist groups and multi-day intraday view

This commit is contained in:
shy3130
2026-08-10 11:39:08 +08:00
parent 99bdec875d
commit 2a2d3b764e
25 changed files with 2433 additions and 257 deletions
+127 -2
View File
@@ -268,6 +268,47 @@ def _get_price_limit_info(
return info
def _get_previous_closes(
repo,
symbol: str,
trade_dates: list[date],
asset_type: str,
) -> dict[date, float | None]:
"""Return the previous trading day's adjusted close for each session."""
if not trade_dates:
return {}
start = min(trade_dates) - timedelta(days=45)
end = max(trade_dates)
try:
daily = repo.get_daily_asset(
asset_type,
symbol,
start,
end,
columns=["date", "close"],
).sort("date")
except Exception:
daily = None
if daily is None or daily.is_empty():
return {trade_date: None for trade_date in trade_dates}
closes: list[tuple[date, float]] = []
for daily_date, close in daily.select(["date", "close"]).iter_rows():
if close is None:
continue
numeric = float(close)
if math.isfinite(numeric) and numeric > 0:
closes.append((daily_date, numeric))
result: dict[date, float | None] = {}
for trade_date in trade_dates:
result[trade_date] = next(
(close for daily_date, close in reversed(closes) if daily_date < trade_date),
None,
)
return result
@router.get("/daily")
def get_daily(
request: Request,
@@ -679,6 +720,73 @@ def get_minute_batch(request: Request, body: dict):
return {"data": result}
@router.get("/minute-range")
def get_minute_range(
request: Request,
symbol: str = Query(..., description="标的代码"),
days: int = Query(10, ge=1, le=20, description="最近交易日数量"),
):
"""读取单只标的最近 N 个已落库交易日的分钟 K。"""
import polars as pl
repo = request.app.state.repo
asset_type = repo.resolve_asset_type(symbol)
stock_info = (
_get_stock_info(repo, symbol)
if asset_type == "stock"
else _get_asset_info(repo, symbol, asset_type)
)
base_response = {
"symbol": symbol,
"name": stock_info.get("name"),
"asset_type": asset_type,
"requested_days": days,
}
# 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。
if asset_type == "index":
return {**base_response, "sessions": [], "source": "none"}
end = cn_today()
start = end - timedelta(days=days * 3 + 20)
minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type)
if minute.is_empty() or "datetime" not in minute.columns:
return {**base_response, "sessions": [], "source": "none"}
minute = minute.with_columns(
pl.col("datetime").dt.date().alias("_trade_date"),
)
trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:]
previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type)
row_columns = [
column
for column in (
"datetime", "open", "high", "low", "close", "volume", "amount"
)
if column in minute.columns
]
sessions = []
for trade_date in trade_dates:
rows = (
minute.filter(pl.col("_trade_date") == trade_date)
.sort("datetime")
.select(row_columns)
.to_dicts()
)
if rows:
sessions.append({
"date": trade_date.isoformat(),
"prev_close": previous_closes.get(trade_date),
"rows": rows,
})
return {
**base_response,
"sessions": sessions,
"source": "local" if sessions else "none",
}
@router.get("/minute")
def get_minute(
request: Request,
@@ -721,13 +829,20 @@ def get_minute(
price_limit = _get_price_limit_info(
repo, symbol, trade_date, asset_type, stock_name,
)
prev_close = _get_previous_closes(
repo, symbol, [trade_date], asset_type,
).get(trade_date)
return {
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
"date": str(trade_date), "rows": df.to_dicts(), "source": "live",
"asset_type": asset_type,
"price_limit": price_limit,
"prev_close": prev_close,
}
prev_close = _get_previous_closes(
repo, symbol, [trade_date], asset_type,
).get(trade_date)
price_limit = _get_price_limit_info(
repo, symbol, trade_date, asset_type, stock_name,
)
@@ -758,6 +873,7 @@ def get_minute(
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
"asset_type": asset_type,
"price_limit": price_limit,
"prev_close": prev_close,
}
# 本地不完整或无数据 → 从 TickFlow 实时拉取
@@ -768,6 +884,7 @@ def get_minute(
"source": "live" if not live_df.is_empty() else "none",
"asset_type": asset_type,
"price_limit": price_limit,
"prev_close": prev_close,
}
@@ -909,13 +1026,21 @@ async def sync_minute_single(request: Request, body: dict):
body: { "symbol": "000001.SZ" }
用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。
"""
import asyncio
from app.services.preferences import get_minute_sync_days
from app.tickflow.capabilities import Cap
symbol = body.get("symbol", "").strip()
if not symbol:
raise HTTPException(status_code=400, detail="symbol 不能为空")
requested_days = body.get("days")
if requested_days is not None:
if isinstance(requested_days, bool) or not isinstance(requested_days, int):
raise HTTPException(status_code=400, detail="days 必须是整数")
if requested_days < 1 or requested_days > 30:
raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间")
repo = request.app.state.repo
capset = request.app.state.capabilities
@@ -927,7 +1052,7 @@ async def sync_minute_single(request: Request, body: dict):
if not _minute_allowed(capset):
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
days = get_minute_sync_days()
days = requested_days if requested_days is not None else get_minute_sync_days()
loop = asyncio.get_event_loop()
def _run():
+65 -9
View File
@@ -36,11 +36,22 @@ _OCR_LIMITER = anyio.CapacityLimiter(2)
class AddRequest(BaseModel):
symbol: str
note: str = ""
group_id: str | None = None
class BatchAddRequest(BaseModel):
symbols: list[str]
note: str = ""
group_id: str | None = None
class GroupNameRequest(BaseModel):
name: str
color: str | None = None
class GroupAssignRequest(BaseModel):
group_id: str | None = None
def _with_names(rows: list[dict], request: Request) -> list[dict]:
@@ -64,20 +75,54 @@ def list_all(request: Request):
@router.post("")
def add_one(req: AddRequest, request: Request):
rows = watchlist.add(req.symbol, req.note)
try:
rows = watchlist.add(req.symbol, req.note, req.group_id)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.post("/batch")
def add_batch(req: BatchAddRequest, request: Request):
existing = {r["symbol"] for r in watchlist.list_symbols()}
added = 0
for sym in req.symbols:
if sym not in existing:
added += 1
existing.add(sym)
watchlist.add(sym, req.note)
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added}
try:
rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request), "added": added}
@router.get("/groups")
def list_groups():
return {"groups": watchlist.list_groups()}
@router.post("/groups")
def create_group(req: GroupNameRequest):
try:
groups, group = watchlist.create_group(req.name, req.color)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups, "group": group}
@router.put("/groups/{group_id}")
def rename_group(group_id: str, req: GroupNameRequest):
try:
groups = watchlist.rename_group(group_id, req.name, req.color)
except KeyError as e:
raise HTTPException(404, "自选分组不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups}
@router.delete("/groups/{group_id}")
def delete_group(group_id: str, request: Request):
try:
groups, rows = watchlist.delete_group(group_id)
except KeyError as e:
raise HTTPException(404, "自选分组不存在") from e
return {"groups": groups, "symbols": _with_names(rows, request)}
@router.get("/ocr-status")
@@ -131,6 +176,17 @@ def move_one_to_top(symbol: str, request: Request):
return {"symbols": _with_names(rows, request)}
@router.put("/{symbol}/group")
def assign_group(symbol: str, req: GroupAssignRequest, request: Request):
try:
rows = watchlist.set_group(symbol, req.group_id)
except KeyError as e:
raise HTTPException(404, "自选标的不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.delete("/{symbol}")
def remove_one(symbol: str, request: Request):
rows = watchlist.remove(symbol)
+240 -51
View File
@@ -1,10 +1,17 @@
"""自选股服务(§6.1)
"""自选股与分组服务。
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的
``watchlist_groups.json``。历史 Parquet 缺少 ``group_id`` 时按未分组读取。
"""
from __future__ import annotations
import json
import logging
import os
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
from datetime import datetime
from pathlib import Path
@@ -17,6 +24,30 @@ from app.tickflow.rate_limits import chunked, resolve_limit
logger = logging.getLogger(__name__)
_LOCK = threading.RLock()
_MAX_GROUP_NAME_LENGTH = 24
DEFAULT_GROUP_COLOR = "sky"
GROUP_COLORS = frozenset({
"sky",
"blue",
"indigo",
"violet",
"fuchsia",
"rose",
"orange",
"amber",
"lime",
"emerald",
"teal",
"cyan",
})
_ENTRY_SCHEMA = {
"symbol": pl.Utf8,
"added_at": pl.Utf8,
"note": pl.Utf8,
"group_id": pl.Utf8,
}
def _path() -> Path:
p = settings.data_dir / "user_data" / "watchlist.parquet"
@@ -24,70 +55,230 @@ def _path() -> Path:
return p
def list_symbols() -> list[dict]:
def _groups_path() -> Path:
p = settings.data_dir / "user_data" / "watchlist_groups.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _empty_entries() -> pl.DataFrame:
return pl.DataFrame(schema=_ENTRY_SCHEMA)
def _read_entries() -> pl.DataFrame:
p = _path()
if not p.exists():
return []
return _empty_entries()
df = pl.read_parquet(p)
if df.is_empty():
return []
return df.to_dicts()
defaults = {"symbol": "", "added_at": "", "note": "", "group_id": None}
for column, dtype in _ENTRY_SCHEMA.items():
if column not in df.columns:
df = df.with_columns(pl.lit(defaults[column], dtype=dtype).alias(column))
return df.select(list(_ENTRY_SCHEMA))
def add(symbol: str, note: str = "") -> list[dict]:
def _write_entries(df: pl.DataFrame) -> None:
p = _path()
if p.exists():
df = pl.read_parquet(p)
# 已存在则先移除,后面重新插入到最前面
if symbol in df["symbol"].to_list():
df = df.filter(pl.col("symbol") != symbol)
else:
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
tmp = p.with_suffix(p.suffix + ".tmp")
df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp)
os.replace(tmp, p)
new_row = pl.DataFrame({
"symbol": [symbol],
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
"note": [note],
})
out = pl.concat([new_row, df], how="diagonal_relaxed")
out.write_parquet(p)
return out.to_dicts()
def _read_groups() -> list[dict]:
p = _groups_path()
if not p.exists():
return []
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("自选分组配置损坏,请检查 watchlist_groups.json") from exc
if not isinstance(raw, list):
raise ValueError("自选分组配置格式不正确")
groups = []
for item in raw:
if not isinstance(item, dict) or not item.get("id") or not item.get("name"):
continue
color = str(item.get("color", DEFAULT_GROUP_COLOR))
groups.append({
"id": str(item["id"]),
"name": str(item["name"]),
"color": color if color in GROUP_COLORS else DEFAULT_GROUP_COLOR,
})
return groups
def _write_groups(groups: list[dict]) -> None:
p = _groups_path()
tmp = p.with_suffix(p.suffix + ".tmp")
tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, p)
def _normalize_group_name(name: str) -> str:
normalized = name.strip()
if not normalized:
raise ValueError("分组名称不能为空")
if len(normalized) > _MAX_GROUP_NAME_LENGTH:
raise ValueError(f"分组名称不能超过 {_MAX_GROUP_NAME_LENGTH} 个字符")
return normalized
def _normalize_group_color(color: str | None) -> str:
normalized = (color or DEFAULT_GROUP_COLOR).strip().lower()
if normalized not in GROUP_COLORS:
raise ValueError("不支持的分组颜色")
return normalized
def _validate_group_id(group_id: str | None, groups: list[dict]) -> None:
if group_id is not None and not any(group["id"] == group_id for group in groups):
raise ValueError("自选分组不存在")
def list_symbols() -> list[dict]:
with _LOCK:
df = _read_entries()
return [] if df.is_empty() else df.to_dicts()
def add(symbol: str, note: str = "", group_id: str | None = None) -> list[dict]:
rows, _ = add_batch([symbol], note=note, group_id=group_id)
return rows
def add_batch(
symbols: list[str],
note: str = "",
group_id: str | None = None,
) -> tuple[list[dict], int]:
"""批量添加并保持既有语义:每个新处理的标的移动到列表最前面。"""
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
rows = _read_entries().to_dicts()
added = 0
for symbol in symbols:
existing = next((row for row in rows if row["symbol"] == symbol), None)
if existing is None:
added += 1
rows = [row for row in rows if row["symbol"] != symbol]
resolved_group_id = (
group_id if group_id is not None else (existing or {}).get("group_id")
)
rows.insert(0, {
"symbol": symbol,
"added_at": datetime.utcnow().isoformat(timespec="seconds"),
"note": note,
"group_id": resolved_group_id,
})
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries()
_write_entries(out)
return out.to_dicts(), added
def remove(symbol: str) -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
df = df.filter(pl.col("symbol") != symbol)
df.write_parquet(p)
return df.to_dicts()
with _LOCK:
df = _read_entries().filter(pl.col("symbol") != symbol)
_write_entries(df)
return df.to_dicts()
def move_to_top(symbol: str) -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
if df.is_empty() or symbol not in df["symbol"].to_list():
return df.to_dicts()
target = df.filter(pl.col("symbol") == symbol)
rest = df.filter(pl.col("symbol") != symbol)
out = pl.concat([target, rest], how="diagonal_relaxed")
out.write_parquet(p)
return out.to_dicts()
with _LOCK:
df = _read_entries()
if df.is_empty() or symbol not in df["symbol"].to_list():
return df.to_dicts()
target = df.filter(pl.col("symbol") == symbol)
rest = df.filter(pl.col("symbol") != symbol)
out = pl.concat([target, rest], how="diagonal_relaxed")
_write_entries(out)
return out.to_dicts()
def clear() -> int:
"""清空自选列表。返回移除的数量。"""
p = _path()
if not p.exists():
return 0
df = pl.read_parquet(p)
count = df.height
if count > 0:
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
return count
with _LOCK:
df = _read_entries()
count = df.height
if count > 0:
_write_entries(_empty_entries())
return count
def list_groups() -> list[dict]:
with _LOCK:
return _read_groups()
def create_group(name: str, color: str | None = None) -> tuple[list[dict], dict]:
with _LOCK:
normalized = _normalize_group_name(name)
normalized_color = _normalize_group_color(color)
groups = _read_groups()
if any(group["name"].casefold() == normalized.casefold() for group in groups):
raise ValueError("分组名称已存在")
group = {
"id": uuid.uuid4().hex,
"name": normalized,
"color": normalized_color,
}
groups.append(group)
_write_groups(groups)
return groups, group
def rename_group(group_id: str, name: str, color: str | None = None) -> list[dict]:
with _LOCK:
normalized = _normalize_group_name(name)
groups = _read_groups()
target = next((group for group in groups if group["id"] == group_id), None)
if target is None:
raise KeyError(group_id)
if any(
group["id"] != group_id and group["name"].casefold() == normalized.casefold()
for group in groups
):
raise ValueError("分组名称已存在")
target["name"] = normalized
if color is not None:
target["color"] = _normalize_group_color(color)
_write_groups(groups)
return groups
def delete_group(group_id: str) -> tuple[list[dict], list[dict]]:
"""删除分组定义,原分组内的自选保留并转为未分组。"""
with _LOCK:
groups = _read_groups()
if not any(group["id"] == group_id for group in groups):
raise KeyError(group_id)
df = _read_entries().with_columns(
pl.when(pl.col("group_id") == group_id)
.then(None)
.otherwise(pl.col("group_id"))
.alias("group_id")
)
remaining = [group for group in groups if group["id"] != group_id]
_write_entries(df)
_write_groups(remaining)
return remaining, df.to_dicts()
def set_group(symbol: str, group_id: str | None) -> list[dict]:
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
df = _read_entries()
if symbol not in df["symbol"].to_list():
raise KeyError(symbol)
df = df.with_columns(
pl.when(pl.col("symbol") == symbol)
.then(pl.lit(group_id, dtype=pl.Utf8))
.otherwise(pl.col("group_id"))
.alias("group_id")
)
_write_entries(df)
return df.to_dicts()
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
@@ -96,8 +287,6 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
"""
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
if not symbols:
return []
+107
View File
@@ -0,0 +1,107 @@
"""多日分时 API 契约。"""
import asyncio
from datetime import date, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.api import kline as kline_api
def _request(repo=None, capset=None):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
repo=repo or MagicMock(),
capabilities=capset or MagicMock(),
)
)
)
def test_minute_range_returns_latest_sessions_with_previous_closes():
repo = MagicMock()
repo.resolve_asset_type.return_value = "stock"
repo.execute_one.return_value = ("浦发银行", 1.0, 1.0)
repo.get_minute_range.return_value = pl.DataFrame({
"symbol": ["600000.SH"] * 3,
"datetime": [
datetime(2026, 8, 5, 1, 30),
datetime(2026, 8, 6, 1, 30),
datetime(2026, 8, 7, 1, 30),
],
"open": [10.0, 11.0, 12.0],
"high": [10.2, 11.2, 12.2],
"low": [9.8, 10.8, 11.8],
"close": [10.1, 11.1, 12.1],
"volume": [100.0, 110.0, 120.0],
"amount": [101_000.0, 122_100.0, 145_200.0],
})
repo.get_daily_asset.return_value = pl.DataFrame({
"date": [
date(2026, 8, 4),
date(2026, 8, 5),
date(2026, 8, 6),
date(2026, 8, 7),
],
"close": [9.9, 10.1, 11.1, 12.1],
})
result = kline_api.get_minute_range(_request(repo), "600000.SH", 2)
assert result["name"] == "浦发银行"
assert result["requested_days"] == 2
assert result["source"] == "local"
assert [session["date"] for session in result["sessions"]] == [
"2026-08-06",
"2026-08-07",
]
assert [session["prev_close"] for session in result["sessions"]] == [
10.1,
11.1,
]
assert result["sessions"][0]["rows"][0]["close"] == 11.1
def test_minute_range_does_not_read_stock_store_for_index():
repo = MagicMock()
repo.resolve_asset_type.return_value = "index"
repo.get_instruments_asset.return_value = pl.DataFrame()
result = kline_api.get_minute_range(_request(repo), "000001.SH", 10)
assert result["asset_type"] == "index"
assert result["sessions"] == []
repo.get_minute_range.assert_not_called()
def test_sync_minute_single_uses_requested_days(monkeypatch):
repo = MagicMock()
repo.resolve_asset_type.return_value = "stock"
capset = MagicMock()
sync = MagicMock(return_value=2400)
refresh = MagicMock()
monkeypatch.setattr(kline_api, "_minute_allowed", lambda _: True)
monkeypatch.setattr(kline_api.kline_sync, "sync_and_persist_minute", sync)
monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh)
result = asyncio.run(kline_api.sync_minute_single(
_request(repo, capset),
{"symbol": "600000.SH", "days": 10},
))
assert result["rows"] == 2400
sync.assert_called_once_with(["600000.SH"], repo, capset, days=10)
refresh.assert_called_once_with(repo, "kline_minute")
def test_sync_minute_single_rejects_invalid_days():
with pytest.raises(HTTPException, match="days 必须在 1 到 30 之间"):
asyncio.run(kline_api.sync_minute_single(
_request(),
{"symbol": "600000.SH", "days": 0},
))
+114
View File
@@ -0,0 +1,114 @@
"""自选分组持久化与 API 契约。"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.api import watchlist as watchlist_api
from app.config import settings
from app.services import watchlist
def _request():
repo = MagicMock()
repo.get_name_map.return_value = {}
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
def test_historical_watchlist_is_read_as_ungrouped(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
path = tmp_path / "user_data" / "watchlist.parquet"
path.parent.mkdir(parents=True)
pl.DataFrame({
"symbol": ["600000.SH"],
"added_at": ["2026-08-08T10:00:00"],
"note": [""],
}).write_parquet(path)
assert watchlist.list_symbols()[0]["group_id"] is None
def test_group_lifecycle_preserves_watchlist_entries(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
groups, created = watchlist.create_group(" 短线 ", "orange")
assert groups == [{"id": created["id"], "name": "短线", "color": "orange"}]
watchlist.add("600000.SH", group_id=created["id"])
watchlist.add("000001.SZ")
assert watchlist.list_symbols()[1]["group_id"] == created["id"]
renamed = watchlist.rename_group(created["id"], "观察", "fuchsia")
assert renamed[0]["name"] == "观察"
assert renamed[0]["color"] == "fuchsia"
remaining, rows = watchlist.delete_group(created["id"])
assert remaining == []
assert {row["symbol"] for row in rows} == {"600000.SH", "000001.SZ"}
assert all(row["group_id"] is None for row in rows)
def test_group_validation_and_assignment_errors(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, created = watchlist.create_group("核心")
watchlist.add("600000.SH")
with pytest.raises(ValueError, match="已存在"):
watchlist.create_group("核心")
with pytest.raises(ValueError, match="颜色"):
watchlist.create_group("无效颜色", "black")
with pytest.raises(ValueError, match="颜色"):
watchlist.rename_group(created["id"], "核心", "black")
with pytest.raises(ValueError, match="不存在"):
watchlist.set_group("600000.SH", "missing")
with pytest.raises(KeyError):
watchlist.set_group("000001.SZ", created["id"])
rows = watchlist.set_group("600000.SH", created["id"])
assert rows[0]["group_id"] == created["id"]
rows = watchlist.set_group("600000.SH", None)
assert rows[0]["group_id"] is None
def test_group_api_contract(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
request = _request()
created = watchlist_api.create_group(
watchlist_api.GroupNameRequest(name="中线", color="teal")
)
group_id = created["group"]["id"]
assert created["group"]["color"] == "teal"
added = watchlist_api.add_one(
watchlist_api.AddRequest(symbol="600000.SH", group_id=group_id),
request,
)
assert added["symbols"][0]["group_id"] == group_id
moved = watchlist_api.assign_group(
"600000.SH",
watchlist_api.GroupAssignRequest(group_id=None),
request,
)
assert moved["symbols"][0]["group_id"] is None
with pytest.raises(HTTPException) as exc_info:
watchlist_api.rename_group("missing", watchlist_api.GroupNameRequest(name="无效"))
assert exc_info.value.status_code == 404
def test_historical_groups_default_to_sky(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
path = tmp_path / "user_data" / "watchlist_groups.json"
path.parent.mkdir(parents=True)
path.write_text(
'[{"id":"legacy","name":"旧分组"},'
'{"id":"invalid","name":"未知颜色","color":"black"}]',
encoding="utf-8",
)
assert watchlist.list_groups() == [
{"id": "legacy", "name": "旧分组", "color": "sky"},
{"id": "invalid", "name": "未知颜色", "color": "sky"},
]
+12 -1
View File
@@ -1958,6 +1958,15 @@ 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 = "pypinyin"
version = "0.55.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" },
]
[[package]]
name = "pytesseract"
version = "0.3.13"
@@ -2504,7 +2513,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.83"
version = "0.1.88"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
@@ -2523,6 +2532,7 @@ dependencies = [
{ name = "pyarrow" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pypinyin" },
{ name = "pytesseract" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
@@ -2570,6 +2580,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=16.0" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.4" },
{ name = "pypinyin", specifier = ">=0.50" },
{ name = "pytesseract", specifier = ">=0.3.10" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
+4 -46
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { MinuteKlineRow, PriceLimitInfo } from '@/lib/api'
import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart'
import { useChartTheme, type ChartTheme } from '@/lib/theme'
type YMode = 'adaptive' | 'limit'
@@ -26,26 +27,6 @@ interface Props {
showAvgLine?: boolean
}
function fmtTime(dt: string): string {
const match = dt.match(/(\d{2}):(\d{2})/)
if (!match) return dt.slice(11, 16)
const h = (parseInt(match[1]) + 8) % 24
return `${String(h).padStart(2, '0')}:${match[2]}`
}
function computeAvgPrice(data: MinuteKlineRow[]): number[] {
// 分时均线 = 累计成交额 / 累计成交量(手→股)
const result: number[] = []
let sumAmt = 0
let sumVol = 0
for (const d of data) {
sumAmt += d.amount
sumVol += d.volume * 100
result.push(sumVol > 0 ? sumAmt / sumVol : d.close)
}
return result
}
function fmtAmt(v: number): string {
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
@@ -56,29 +37,6 @@ function isValidPrice(v: number | null | undefined): v is number {
return typeof v === 'number' && Number.isFinite(v) && v > 0
}
/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */
function generateFullDayTimes(): string[] {
const times: string[] = []
// 上午 9:30 ~ 11:30 (121 分钟)
for (let h = 9; h <= 11; h++) {
const startM = h === 9 ? 30 : 0
const endM = h === 11 ? 30 : 59
for (let m = startM; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
// 下午 13:00 ~ 15:00 (121 分钟)
for (let h = 13; h <= 15; h++) {
const endM = h === 15 ? 0 : 59
for (let m = 0; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
return times
}
const FULL_DAY_TIMES = generateFullDayTimes()
/** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */
function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): {
limitUp: number // 涨停价 (四舍五入)
@@ -112,7 +70,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
const volNeutral = 'rgba(161,161,170,0.5)'
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const timeKey = formatMinuteTime(data[i].datetime)
const idx = timeIndexMap.get(timeKey)
if (idx !== undefined) {
closes[idx] = data[i].close
@@ -414,7 +372,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi
const [infoIdx, setInfoIdx] = useState(data.length - 1)
const [yMode, setYMode] = useState<YMode>('adaptive')
const ct = useChartTheme()
const avgPrices = useMemo(() => computeAvgPrice(data), [data])
const avgPrices = useMemo(() => computeIntradayAverage(data), [data])
// 分时线颜色:基于最新价 vs 昨收
const lastClose = data.length > 0 ? data[data.length - 1].close : null
@@ -480,7 +438,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const mapping = new Map<number, number>()
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const timeKey = formatMinuteTime(data[i].datetime)
const fullDayIdx = timeIndexMap.get(timeKey)
if (fullDayIdx !== undefined) {
mapping.set(fullDayIdx, i)
@@ -0,0 +1,373 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { MinuteKlineRow, MinuteKlineSession } from '@/lib/api'
import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart'
import { useChartTheme } from '@/lib/theme'
const COLORS = {
up: '#C74040',
down: '#2D9B65',
flat: '#A1A1AA',
average: '#F59E0B',
volumeUp: 'rgba(240,68,56,0.58)',
volumeDown: 'rgba(18,183,106,0.58)',
volumeFlat: 'rgba(161,161,170,0.45)',
}
interface Props {
sessions: MinuteKlineSession[]
height?: number
}
interface InfoPoint {
date: string
row: MinuteKlineRow
average: number
prevClose: number | null
}
function formatAmount(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}亿`
if (value >= 10_000) return `${(value / 10_000).toFixed(0)}`
return value.toFixed(0)
}
function priceColor(close: number, prevClose: number | null): string {
if (prevClose == null || close === prevClose) return COLORS.flat
return close > prevClose ? COLORS.up : COLORS.down
}
function buildModel(sessions: MinuteKlineSession[]) {
const categories: string[] = []
const volumeData: ({ value: number; itemStyle: { color: string } } | null)[] = []
const dayLabelByIndex = new Map<number, string>()
const dayStartIndexes: number[] = []
const pointByIndex = new Map<number, InfoPoint>()
const dayRanges: {
start: number
session: MinuteKlineSession
values: (number | null)[]
averages: (number | null)[]
}[] = []
const priceValues: number[] = []
const labelStep = Math.max(1, Math.ceil(sessions.length / 10))
for (let sessionIndex = 0; sessionIndex < sessions.length; sessionIndex++) {
const session = sessions[sessionIndex]
const start = categories.length
dayStartIndexes.push(start)
if (sessionIndex % labelStep === 0 || sessionIndex === sessions.length - 1) {
dayLabelByIndex.set(start + Math.floor(FULL_DAY_TIMES.length / 2), session.date.slice(5))
}
const averagePrices = computeIntradayAverage(session.rows)
const rowsByTime = new Map<string, { row: MinuteKlineRow; average: number }>()
session.rows.forEach((row, index) => {
rowsByTime.set(formatMinuteTime(row.datetime), {
row,
average: averagePrices[index],
})
})
const dayValues: (number | null)[] = []
const dayAverages: (number | null)[] = []
for (const time of FULL_DAY_TIMES) {
const point = rowsByTime.get(time)
const index = categories.length
categories.push(`${session.date} ${time}`)
if (!point) {
dayValues.push(null)
dayAverages.push(null)
volumeData.push(null)
continue
}
const { row, average } = point
dayValues.push(row.close)
dayAverages.push(average)
volumeData.push({
value: row.volume,
itemStyle: {
color: row.close > row.open
? COLORS.volumeUp
: row.close < row.open
? COLORS.volumeDown
: COLORS.volumeFlat,
},
})
priceValues.push(row.low, row.high, average)
pointByIndex.set(index, {
date: session.date,
row,
average,
prevClose: session.prev_close,
})
}
dayRanges.push({
start,
session,
values: dayValues,
averages: dayAverages,
})
if (sessionIndex < sessions.length - 1) {
categories.push(`${session.date} gap`)
volumeData.push(null)
}
}
return {
categories,
volumeData,
dayLabelByIndex,
dayStartIndexes,
pointByIndex,
dayRanges,
priceValues,
latest: pointByIndex.size > 0
? Array.from(pointByIndex.values())[pointByIndex.size - 1]
: null,
}
}
export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const model = useMemo(() => buildModel(sessions), [sessions])
const modelRef = useRef(model)
modelRef.current = model
const [info, setInfo] = useState<InfoPoint | null>(model.latest)
const theme = useChartTheme()
useEffect(() => {
setInfo(model.latest)
}, [model])
useEffect(() => {
const container = containerRef.current
if (!container) return
let chart = chartRef.current
if (!chart) {
chart = echarts.init(container, undefined, { renderer: 'canvas' })
chartRef.current = chart
resizeObserverRef.current = new ResizeObserver(() => chart?.resize())
resizeObserverRef.current.observe(container)
chart.on('updateAxisPointer', (event: any) => {
const axisInfo = event.axesInfo?.find((item: any) => item.axisDim === 'x' && item.axisIndex === 0)
?? event.axesInfo?.[0]
const rawValue = axisInfo?.value
const current = modelRef.current
const index = typeof rawValue === 'number'
? rawValue
: current.categories.indexOf(String(rawValue))
const point = current.pointByIndex.get(index)
if (point) setInfo(point)
})
chart.on('globalout', () => setInfo(modelRef.current.latest))
}
const minPrice = model.priceValues.length > 0 ? Math.min(...model.priceValues) : 0
const maxPrice = model.priceValues.length > 0 ? Math.max(...model.priceValues) : 1
const padding = Math.max((maxPrice - minPrice) * 0.08, maxPrice * 0.002)
const totalLength = model.categories.length
const priceSeries: any[] = model.dayRanges.map(({ start, session, values }) => {
const data = new Array(totalLength).fill(null) as (number | null)[]
for (let index = 0; index < values.length; index++) data[start + index] = values[index]
const last = session.rows[session.rows.length - 1]
const color = last ? priceColor(last.close, session.prev_close) : COLORS.flat
return {
name: session.date,
type: 'line',
data,
symbol: 'none',
smooth: false,
connectNulls: true,
lineStyle: { width: 1.2, color },
areaStyle: { color, opacity: 0.08 },
emphasis: { disabled: true },
}
})
const boundaryData = model.dayStartIndexes.slice(1).map(index => ({
xAxis: model.categories[index],
lineStyle: { color: theme.grid, width: 1 },
label: { show: false },
}))
if (priceSeries.length > 0 && boundaryData.length > 0) {
priceSeries[0].markLine = {
symbol: 'none',
silent: true,
data: boundaryData,
}
}
const averageSeries: any[] = model.dayRanges.map(({ start, session, averages }) => {
const data = new Array(totalLength).fill(null) as (number | null)[]
for (let index = 0; index < averages.length; index++) data[start + index] = averages[index]
return {
name: `${session.date} 均价`,
type: 'line',
data,
symbol: 'none',
connectNulls: true,
lineStyle: { width: 1, color: COLORS.average },
emphasis: { disabled: true },
}
})
const option: EChartsOption = {
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: 'transparent',
borderWidth: 0,
formatter: () => '',
axisPointer: {
type: 'cross',
label: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
borderWidth: 1,
color: theme.tooltipText,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
},
crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
},
},
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [
{ left: 58, right: 18, top: 16, bottom: '28%' },
{ left: 58, right: 18, top: '76%', bottom: 22 },
],
xAxis: [
{
type: 'category',
data: model.categories,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.grid } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
color: theme.text,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
interval: 0,
hideOverlap: true,
formatter: (_value: string, index: number) => model.dayLabelByIndex.get(index) ?? '',
},
axisPointer: {
label: {
formatter: (params: any) => {
const value = String(params.value ?? '')
return value.endsWith(' gap') ? '' : value.slice(5)
},
},
},
},
{
type: 'category',
gridIndex: 1,
data: model.categories,
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
type: 'value',
min: minPrice - padding,
max: maxPrice + padding,
scale: true,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid } },
axisLabel: {
color: theme.text,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
formatter: (value: number) => value.toFixed(2),
},
},
{
type: 'value',
gridIndex: 1,
scale: true,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
dataZoom: [{
type: 'inside',
xAxisIndex: [0, 1],
start: 0,
end: 100,
minValueSpan: FULL_DAY_TIMES.length,
filterMode: 'none',
}],
series: [
...priceSeries,
...averageSeries,
{
name: '成交量',
type: 'bar',
data: model.volumeData,
xAxisIndex: 1,
yAxisIndex: 1,
},
],
}
chart.setOption(option, true)
}, [height, model, theme])
useEffect(() => () => {
chartRef.current?.off('updateAxisPointer')
chartRef.current?.off('globalout')
resizeObserverRef.current?.disconnect()
chartRef.current?.dispose()
chartRef.current = null
}, [])
const changePct = info?.prevClose
? (info.row.close - info.prevClose) / info.prevClose * 100
: null
const infoColor = info ? priceColor(info.row.close, info.prevClose) : COLORS.flat
const rowCount = sessions.reduce((total, session) => total + session.rows.length, 0)
return (
<div className="w-full overflow-hidden">
<div className="flex min-h-10 flex-wrap items-center justify-between gap-x-4 gap-y-1 px-2 py-1 font-mono text-[11px]" style={{ backgroundColor: theme.infoBarBg }}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2">
{info ? (
<>
<span className="text-muted">{info.date} {formatMinuteTime(info.row.datetime)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.open.toFixed(2)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.high.toFixed(2)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.low.toFixed(2)}</span>
<span className="text-muted"></span><span className="font-semibold" style={{ color: infoColor }}>{info.row.close.toFixed(2)}</span>
{changePct != null && (
<span style={{ color: infoColor }}>{changePct >= 0 ? '+' : ''}{changePct.toFixed(2)}%</span>
)}
<span className="text-muted"></span><span style={{ color: COLORS.average }}>{info.average.toFixed(2)}</span>
<span className="text-muted"></span><span className="text-secondary">{info.row.volume.toFixed(0)}</span>
<span className="text-muted"></span><span className="text-secondary">{formatAmount(info.row.amount)}</span>
</>
) : <span className="text-muted"></span>}
</div>
<div className="shrink-0 text-[10px] text-muted">{sessions.length} · {rowCount} </div>
</div>
<div ref={containerRef} className="w-full" style={{ height: height - 40, cursor: 'crosshair' }} />
</div>
)
}
+36 -8
View File
@@ -3,6 +3,7 @@ import { Settings2, RadioTower, Star } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
const BULL = '#C74040'
@@ -20,9 +21,11 @@ interface Props {
financialMetrics?: FinancialMetricRecord
/** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */
onMonitor?: () => void
/** 自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */
/** 自选状态与操作(传入对应回调时渲染 Star 图标 */
inWatchlist?: boolean
onToggleWatchlist?: () => void
onAddToWatchlist?: (groupId: string | null) => void
onRemoveFromWatchlist?: () => void
watchlistPending?: boolean
}
/**
@@ -91,7 +94,20 @@ function renderExtInline(
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) {
export function StockInfoBar({
symbol,
name,
stockInfo,
rows,
fields,
onFieldsChange,
financialMetrics,
onMonitor,
inWatchlist,
onAddToWatchlist,
onRemoveFromWatchlist,
watchlistPending,
}: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
@@ -216,15 +232,27 @@ export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsCh
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{onToggleWatchlist && (
{inWatchlist && onRemoveFromWatchlist ? (
<button
onClick={onToggleWatchlist}
className={`p-1 rounded-btn transition-colors cursor-pointer ${inWatchlist ? 'text-[#FACC15]' : 'text-muted hover:text-foreground hover:bg-elevated'}`}
title={inWatchlist ? '移出自选' : '加自选'}
type="button"
onClick={onRemoveFromWatchlist}
disabled={watchlistPending}
className="rounded-btn p-1 text-[#FACC15] transition-colors cursor-pointer hover:bg-elevated disabled:opacity-50"
title="移出自选"
aria-label={`${symbol} 移出自选`}
>
<Star className="h-3.5 w-3.5" />
</button>
)}
) : !inWatchlist && onAddToWatchlist ? (
<WatchlistAddMenu
onSelect={onAddToWatchlist}
disabled={watchlistPending}
triggerClassName="rounded-btn p-1 text-muted transition-colors cursor-pointer hover:bg-elevated hover:text-foreground disabled:opacity-50"
ariaLabel={`${symbol} 加入自选`}
>
<Star className="h-3.5 w-3.5" />
</WatchlistAddMenu>
) : null}
{onMonitor && (
<button
onClick={onMonitor}
@@ -0,0 +1,152 @@
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Download, Loader2, RefreshCw } from 'lucide-react'
import { api, type MinuteKlineSession } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday'
interface Props {
symbol: string
days: number
height?: number
refetchIntervalMs?: number
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '分钟数据获取失败'
}
export function StockMultiDayIntradayChart({
symbol,
days,
height = 420,
refetchIntervalMs,
}: Props) {
const queryClient = useQueryClient()
const history = useQuery({
queryKey: QK.klineMinuteRange(symbol, days),
queryFn: () => api.klineMinuteRange(symbol, days),
enabled: !!symbol,
})
const latest = useQuery({
queryKey: QK.klineMinute(symbol, ''),
queryFn: () => api.klineMinute(symbol),
enabled: !!symbol,
refetchInterval: refetchIntervalMs,
})
const sessions = useMemo(() => {
const byDate = new Map<string, MinuteKlineSession>()
for (const session of history.data?.sessions ?? []) byDate.set(session.date, session)
const latestDate = latest.data?.date
const latestRows = latest.data?.rows ?? []
if (latestDate && latestRows.length > 0) {
const existing = byDate.get(latestDate)
byDate.set(latestDate, {
date: latestDate,
prev_close: latest.data?.prev_close ?? existing?.prev_close ?? null,
rows: latestRows,
})
}
return Array.from(byDate.values())
.sort((left, right) => left.date.localeCompare(right.date))
.slice(-days)
}, [days, history.data?.sessions, latest.data])
const syncMinute = useMutation({
mutationFn: () => api.syncMinuteSingle(symbol, days),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['kline-minute-range', symbol] }),
queryClient.invalidateQueries({ queryKey: ['kline-minute', symbol] }),
])
},
})
const loading = sessions.length === 0 && (history.isLoading || latest.isLoading)
const queryError = sessions.length === 0 ? history.error ?? latest.error : null
const isIndex = history.data?.asset_type === 'index' || latest.data?.asset_type === 'index'
const missingDays = Math.max(0, days - sessions.length)
const showCoverage = sessions.length > 0 && missingDays > 0 && !isIndex
const chartHeight = Math.max(260, height - (showCoverage ? 32 : 0))
if (loading) {
return (
<div className="flex items-center justify-center gap-2 text-xs text-muted" style={{ height }}>
<Loader2 className="h-4 w-4 animate-spin text-accent" />
{days}
</div>
)
}
if (queryError) {
return (
<div className="flex flex-col items-center justify-center gap-3 text-xs" style={{ height }}>
<span className="text-danger">{errorMessage(queryError)}</span>
<button
type="button"
onClick={() => { void history.refetch(); void latest.refetch() }}
className="inline-flex items-center gap-1.5 rounded-btn border border-border bg-elevated px-3 py-1.5 text-secondary hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)
}
if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 text-xs" style={{ height }}>
{syncMinute.isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-accent" />
<span className="text-secondary"> {days} K</span>
</>
) : (
<>
<span className="text-muted">{isIndex ? '指数暂无分钟数据' : '本地暂无可展示的分钟数据'}</span>
{!isIndex && (
<button
type="button"
onClick={() => syncMinute.mutate()}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
>
<Download className="h-3.5 w-3.5" />
{days}
</button>
)}
</>
)}
{syncMinute.isError && <span className="max-w-md text-center text-danger">{errorMessage(syncMinute.error)}</span>}
</div>
)
}
return (
<div style={{ height }}>
{showCoverage && (
<div className="flex h-8 items-center justify-between gap-3 border-b border-border/60 bg-elevated/40 px-3 text-[11px]">
<span className="truncate text-muted"> {sessions.length} {days} </span>
<button
type="button"
disabled={syncMinute.isPending}
onClick={() => syncMinute.mutate()}
className="inline-flex shrink-0 items-center gap-1 text-accent hover:text-accent/80 disabled:opacity-60"
>
{syncMinute.isPending
? <Loader2 className="h-3 w-3 animate-spin" />
: <Download className="h-3 w-3" />}
</button>
</div>
)}
<EChartsMultiDayIntraday sessions={sessions} height={chartHeight} />
{syncMinute.isError && (
<div className="px-3 pt-1 text-center text-[11px] text-danger">{errorMessage(syncMinute.error)}</div>
)}
</div>
)
}
+10 -4
View File
@@ -29,9 +29,11 @@ interface Props {
showMarkerToggle?: boolean
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
onMonitor?: () => void
/** 自选 (传入后信息条显示 Star 图标) */
/** 自选操作(传入后信息条显示 Star 图标 */
inWatchlist?: boolean
onToggleWatchlist?: () => void
onAddToWatchlist?: (groupId: string | null) => void
onRemoveFromWatchlist?: () => void
watchlistPending?: boolean
/** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */
refetchIntervalMs?: number
}
@@ -52,7 +54,9 @@ export function StockPanel({
showMarkerToggle = true,
onMonitor,
inWatchlist,
onToggleWatchlist,
onAddToWatchlist,
onRemoveFromWatchlist,
watchlistPending,
refetchIntervalMs,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
@@ -132,7 +136,9 @@ export function StockPanel({
financialMetrics={financialMetrics}
onMonitor={onMonitor}
inWatchlist={inWatchlist}
onToggleWatchlist={onToggleWatchlist}
onAddToWatchlist={onAddToWatchlist}
onRemoveFromWatchlist={onRemoveFromWatchlist}
watchlistPending={watchlistPending}
/>
<div className="flex gap-3 items-start">
+127 -46
View File
@@ -1,16 +1,18 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock } from 'lucide-react'
import { X, RefreshCw, Clock, LineChart } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { storage } from '@/lib/storage'
interface Props {
symbol: string | null
@@ -34,6 +36,16 @@ const PRESETS: { label: string; months: number }[] = [
{ label: '1年', months: 12 },
]
type PreviewView = 'daily' | 'intraday'
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
function loadIntradayDays(): number {
const saved = storage.stockPreviewIntradayDays.get(10)
return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number])
? saved
: 10
}
function boardTag(symbol: string): { label: string; color: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
@@ -42,7 +54,8 @@ function boardTag(symbol: string): { label: string; color: string } | null {
}
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
const [view, setView] = useState<PreviewView>('daily')
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
@@ -56,7 +69,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
const toggleWatchlist = useMutation({
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
mutationFn: ({
action,
groupId,
}: {
action: 'add' | 'remove'
groupId?: string | null
}) => action === 'remove'
? api.watchlistRemove(symbol!)
: api.watchlistAdd(symbol!, '', groupId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
@@ -73,6 +94,10 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
return () => document.removeEventListener('keydown', handler)
}, [symbol, onClose])
useEffect(() => {
if (symbol) setView('daily')
}, [symbol])
// 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K,
// 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。
// 关闭/切股时清除, 避免无谓刷新。
@@ -94,12 +119,19 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
const handleRefresh = () => {
if (!symbol) return
qc.invalidateQueries({ queryKey: ['kline', symbol!] })
if (showIntraday) {
if (view === 'daily') {
qc.invalidateQueries({ queryKey: ['kline', symbol] })
} else {
qc.invalidateQueries({ queryKey: ['kline-minute-range', symbol] })
qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] })
}
}
const selectIntradayDays = (days: number) => {
setIntradayDays(days)
storage.stockPreviewIntradayDays.set(days)
}
return (
<AnimatePresence>
{symbol && (
@@ -123,8 +155,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
className="relative w-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-5 shrink-0">
<div className="flex min-w-0 items-center gap-2">
{(() => {
const board = symbol ? boardTag(symbol) : null
return board ? (
@@ -133,11 +165,51 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
</span>
) : null
})()}
<span className="font-mono text-sm font-medium text-foreground">{symbol}</span>
{name && <span className="text-xs text-muted">{name}</span>}
<span className="shrink-0 font-mono text-sm font-medium text-foreground">{symbol}</span>
{name && <span className="truncate text-xs text-muted">{name}</span>}
</div>
<div className="flex items-center gap-1.5">
<button
onClick={onClose}
className="shrink-0 rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
aria-label="关闭个股详情"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-y border-border px-4 py-2 sm:px-5">
<div role="tablist" aria-label="图表视图" className="inline-flex shrink-0 items-center rounded border border-border bg-elevated p-0.5">
<button
type="button"
role="tab"
aria-selected={view === 'daily'}
onClick={() => setView('daily')}
className={`inline-flex h-6 items-center gap-1 rounded px-2 text-[11px] transition-colors ${
view === 'daily' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<LineChart className="h-3 w-3" />
K
</button>
<button
type="button"
role="tab"
aria-selected={view === 'intraday'}
onClick={() => setView('intraday')}
className={`inline-flex h-6 items-center gap-1 rounded px-2 text-[11px] transition-colors ${
view === 'intraday' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex max-w-full min-w-0 items-center gap-1.5 overflow-x-auto">
{view === 'daily' ? (
<>
{/* 日期范围快捷 */}
{PRESETS.map(p => {
const now = new Date()
@@ -175,23 +247,31 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
min={dateRange.start}
/>
</>
) : (
<>
<span className="shrink-0 text-[10px] text-muted"></span>
<div className="inline-flex shrink-0 items-center rounded border border-border bg-elevated p-0.5" aria-label="分时周期">
{INTRADAY_DAY_OPTIONS.map(days => (
<button
key={days}
type="button"
aria-pressed={intradayDays === days}
onClick={() => selectIntradayDays(days)}
className={`h-5 rounded px-1.5 font-mono text-[10px] transition-colors ${
intradayDays === days
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
{days}
</button>
))}
</div>
</>
)}
<span className="text-muted/20 mx-0.5">|</span>
{/* 分时开关 */}
<button
onClick={() => setShowIntraday((v) => !v)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
showIntraday
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary border border-border hover:border-accent/30'
}`}
>
<Clock className="h-3 w-3" />
</button>
<span className="text-muted/20 mx-0.5">|</span>
<span className="mx-0.5 h-4 w-px shrink-0 bg-border" />
{/* 刷新 */}
<button
@@ -201,14 +281,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
{/* 关闭 */}
<button
onClick={onClose}
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
@@ -253,19 +325,28 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
</div>
)}
{/* K 线内容 */}
{/* 图表内容 */}
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={symbol}
height={420}
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
refetchIntervalMs={intradayRefetchMs}
/>
{view === 'daily' ? (
<StockPanel
symbol={symbol}
height={420}
showIntraday={false}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onAddToWatchlist={groupId => toggleWatchlist.mutate({ action: 'add', groupId })}
onRemoveFromWatchlist={() => toggleWatchlist.mutate({ action: 'remove' })}
watchlistPending={toggleWatchlist.isPending}
/>
) : (
<StockMultiDayIntradayChart
symbol={symbol}
days={intradayDays}
height={480}
refetchIntervalMs={intradayRefetchMs}
/>
)}
</div>
{/* 加监控编辑器弹层 */}
@@ -0,0 +1,268 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useQuery } from '@tanstack/react-query'
import { Check, Folder, Inbox, List, LoaderCircle, RefreshCw } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
const MENU_WIDTH = 224
const MENU_MAX_HEIGHT = 320
const VIEWPORT_GAP = 8
const TRIGGER_GAP = 6
export interface WatchlistGroupMenuProps {
children: ReactNode
onSelect: (groupId: string | null) => void
disabled?: boolean
preferredGroupId?: string | null
includeAll?: boolean
counts?: Record<string, number>
total?: number
disableEmpty?: boolean
menuLabel?: string
align?: 'left' | 'right'
triggerClassName?: string
title?: string
ariaLabel?: string
}
/**
* 自选分组选择菜单。
* 分组仅在菜单打开时读取,React Query 会在多个入口间共享同一份缓存。
*/
export function WatchlistGroupMenu({
children,
onSelect,
disabled = false,
preferredGroupId,
includeAll = false,
counts,
total = 0,
disableEmpty = false,
menuLabel = '选择自选分组',
align = 'right',
triggerClassName = '',
title = '加入自选',
ariaLabel = title,
}: WatchlistGroupMenuProps) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const groupsQuery = useQuery({
queryKey: QK.watchlistGroups,
queryFn: api.watchlistGroups,
enabled: open,
staleTime: 60_000,
})
const groups = groupsQuery.data?.groups ?? []
const showPreferred = preferredGroupId !== undefined
const placeMenu = () => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const menuHeight = Math.min(menuRef.current?.offsetHeight ?? MENU_MAX_HEIGHT, MENU_MAX_HEIGHT)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
const dropUp = spaceBelow < menuHeight + TRIGGER_GAP && spaceAbove > spaceBelow
const top = dropUp
? Math.max(VIEWPORT_GAP, rect.top - menuHeight - TRIGGER_GAP)
: Math.min(rect.bottom + TRIGGER_GAP, window.innerHeight - menuHeight - VIEWPORT_GAP)
const rawLeft = align === 'left' ? rect.left : rect.right - MENU_WIDTH
const left = Math.max(
VIEWPORT_GAP,
Math.min(rawLeft, window.innerWidth - MENU_WIDTH - VIEWPORT_GAP),
)
setPosition({ top, left })
}
const toggleMenu = () => {
if (disabled) return
if (open) {
setOpen(false)
return
}
placeMenu()
setOpen(true)
}
useLayoutEffect(() => {
if (!open) return
placeMenu()
}, [open, groups.length, groupsQuery.isPending])
useEffect(() => {
if (!open) return
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
const closeOnViewportChange = () => setOpen(false)
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener('mousedown', closeOnOutsideClick)
window.addEventListener('keydown', closeOnEscape, true)
window.addEventListener('scroll', closeOnViewportChange, true)
window.addEventListener('resize', closeOnViewportChange)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
window.removeEventListener('keydown', closeOnEscape, true)
window.removeEventListener('scroll', closeOnViewportChange, true)
window.removeEventListener('resize', closeOnViewportChange)
}
}, [open])
useEffect(() => {
if (!open || groupsQuery.isPending) return
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')?.focus()
}, [open, groups.length, groupsQuery.isPending])
const choose = (groupId: string | null) => {
setOpen(false)
onSelect(groupId)
}
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [])
if (items.length === 0) return
event.preventDefault()
const current = items.indexOf(document.activeElement as HTMLButtonElement)
if (event.key === 'Home') items[0].focus()
else if (event.key === 'End') items[items.length - 1].focus()
else if (event.key === 'ArrowDown') items[(current + 1 + items.length) % items.length].focus()
else items[(current - 1 + items.length) % items.length].focus()
}
const menuItemClass = 'flex h-8 w-full items-center gap-2 rounded-btn px-2 text-left text-xs text-secondary outline-none transition-colors hover:bg-elevated hover:text-foreground focus:bg-elevated focus:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-secondary'
const showCounts = counts !== undefined
const ungroupedCount = counts?.ungrouped ?? 0
return (
<>
<button
ref={triggerRef}
type="button"
onClick={event => { event.stopPropagation(); toggleMenu() }}
disabled={disabled}
className={triggerClassName}
title={title}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
>
{children}
</button>
{open && createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={menuLabel}
data-watchlist-group-menu
onKeyDown={handleMenuKeyDown}
style={{ position: 'fixed', top: position.top, left: position.left }}
className="z-[10000] w-56 overflow-hidden rounded-card border border-border bg-surface p-1.5 shadow-[0_10px_32px_rgba(0,0,0,0.42)]"
>
<div className="px-2 pb-1.5 pt-1 text-[10px] font-medium text-muted">{menuLabel}</div>
{groupsQuery.isPending ? (
<div className="flex h-16 items-center justify-center text-muted">
<LoaderCircle className="h-4 w-4 animate-spin" />
</div>
) : groupsQuery.isError ? (
<button
type="button"
onClick={() => void groupsQuery.refetch()}
className="flex h-14 w-full items-center justify-center gap-1.5 rounded-btn text-xs text-danger hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<div className="max-h-60 overflow-y-auto">
{includeAll && (
<>
<button
type="button"
role="menuitem"
onClick={() => choose('all')}
disabled={disableEmpty && total === 0}
className={menuItemClass}
>
<List className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="min-w-0 flex-1 truncate"></span>
{showCounts && <span className="font-mono text-[10px] tabular-nums text-muted">{total}</span>}
</button>
<div className="my-1 border-t border-border/70" />
</>
)}
<button
type="button"
role="menuitem"
onClick={() => choose(null)}
disabled={disableEmpty && ungroupedCount === 0}
className={menuItemClass}
>
<Inbox className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate"></span>
{showCounts && <span className="font-mono text-[10px] tabular-nums text-muted">{ungroupedCount}</span>}
{showPreferred && preferredGroupId == null && (
<Check className="h-3.5 w-3.5 shrink-0 text-accent" aria-label="当前分组" />
)}
</button>
{!includeAll && groups.length > 0 && <div className="my-1 border-t border-border/70" />}
{groups.map(group => {
const color = resolveWatchlistGroupColor(group.color)
return (
<button
key={group.id}
type="button"
role="menuitem"
onClick={() => choose(group.id)}
disabled={disableEmpty && (counts?.[group.id] ?? 0) === 0}
className={`${menuItemClass} border-l-2 ${color.border}`}
title={group.name}
>
<Folder className={`h-3.5 w-3.5 shrink-0 ${color.text}`} />
<span className="min-w-0 flex-1 truncate">{group.name}</span>
{showCounts && <span className="font-mono text-[10px] tabular-nums text-muted">{counts[group.id] ?? 0}</span>}
{showPreferred && preferredGroupId === group.id && (
<Check className={`h-3.5 w-3.5 shrink-0 ${color.text}`} aria-label="当前分组" />
)}
</button>
)
})}
</div>
)}
</div>,
document.body,
)}
</>
)
}
type WatchlistAddMenuProps = Omit<
WatchlistGroupMenuProps,
'includeAll' | 'counts' | 'total' | 'disableEmpty' | 'menuLabel'
>
/** 所有“加入自选”入口共用的目标分组菜单。 */
export function WatchlistAddMenu(props: WatchlistAddMenuProps) {
return <WatchlistGroupMenu {...props} menuLabel="加入到自选分组" />
}
+359
View File
@@ -0,0 +1,359 @@
import { useRef, useState } from 'react'
import { Check, FolderCog, FolderInput, Pencil, Plus, Trash2, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import type { WatchlistGroup, WatchlistGroupColor } from '@/lib/api'
import {
DEFAULT_WATCHLIST_GROUP_COLOR,
WATCHLIST_GROUP_COLORS,
resolveWatchlistGroupColor,
} from '@/lib/watchlist-group-colors'
export type WatchlistGroupFilter = 'all' | 'ungrouped' | string
interface GroupBarProps {
groups: WatchlistGroup[]
counts: Record<string, number>
selected: WatchlistGroupFilter
total: number
onSelect: (group: WatchlistGroupFilter) => void
onCreate: (name: string, color: WatchlistGroupColor) => Promise<void>
onRename: (groupId: string, name: string, color: WatchlistGroupColor) => Promise<void>
onDelete: (groupId: string) => Promise<void>
}
export function WatchlistGroupBar({
groups,
counts,
selected,
total,
onSelect,
onCreate,
onRename,
onDelete,
}: GroupBarProps) {
const [managerOpen, setManagerOpen] = useState(false)
const tabs = [
{ id: 'all', name: '全部', count: total, color: null },
{ id: 'ungrouped', name: '未分组', count: counts.ungrouped ?? 0, color: null },
...groups.map(group => ({ id: group.id, name: group.name, count: counts[group.id] ?? 0, color: group.color })),
]
return (
<>
<div className="flex h-10 items-stretch border-b border-border bg-surface/40 px-5">
<div role="tablist" aria-label="自选分组" className="flex min-w-0 flex-1 items-stretch gap-1 overflow-x-auto">
{tabs.map(tab => {
const active = selected === tab.id
const color = tab.color ? resolveWatchlistGroupColor(tab.color) : null
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onSelect(tab.id)}
className={`my-1.5 inline-flex shrink-0 items-center gap-1.5 rounded-btn border px-3 text-xs transition-colors ${
active
? color
? `${color.text} ${color.border} ${color.background}`
: 'border-accent/40 bg-accent/10 text-accent'
: color
? `border-transparent ${color.text} hover:bg-elevated`
: 'border-transparent text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
{color && <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />}
<span>{tab.name}</span>
<span className={`font-mono text-[10px] tabular-nums ${active && !color ? 'text-accent/80' : 'text-muted'}`}>
{tab.count}
</span>
</button>
)
})}
</div>
<button
type="button"
onClick={() => setManagerOpen(true)}
className="ml-2 inline-flex w-8 shrink-0 items-center justify-center text-muted hover:text-accent"
title="管理自选分组"
aria-label="管理自选分组"
>
<FolderCog className="h-4 w-4" />
</button>
</div>
{managerOpen && (
<GroupManagerDialog
groups={groups}
counts={counts}
onClose={() => setManagerOpen(false)}
onCreate={onCreate}
onRename={onRename}
onDelete={onDelete}
/>
)}
</>
)
}
function GroupColorPicker({
value,
onChange,
}: {
value: WatchlistGroupColor
onChange: (color: WatchlistGroupColor) => void
}) {
return (
<div role="radiogroup" aria-label="分组颜色" className="flex flex-wrap items-center gap-1.5">
{WATCHLIST_GROUP_COLORS.map(option => {
const selected = value === option.id
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={selected}
aria-label={option.label}
title={option.label}
onClick={() => onChange(option.id)}
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border transition-transform hover:scale-110 ${
selected
? `${option.border} ${option.background} ring-2 ${option.ring}`
: 'border-transparent hover:bg-elevated'
}`}
>
<span className={`h-3 w-3 rounded-full ${option.dot}`} />
</button>
)
})}
</div>
)
}
function GroupManagerDialog({
groups,
counts,
onClose,
onCreate,
onRename,
onDelete,
}: Omit<GroupBarProps, 'selected' | 'total' | 'onSelect'> & { onClose: () => void }) {
const inputRef = useRef<HTMLInputElement>(null)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState<WatchlistGroupColor>(DEFAULT_WATCHLIST_GROUP_COLOR)
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [editingColor, setEditingColor] = useState<WatchlistGroupColor>(DEFAULT_WATCHLIST_GROUP_COLOR)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const validate = (name: string) => {
const value = name.trim()
if (!value) return '请输入分组名称'
if (value.length > 24) return '分组名称不能超过 24 个字符'
return ''
}
const run = async (action: () => Promise<void>) => {
setPending(true)
setError('')
try {
await action()
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败')
} finally {
setPending(false)
}
}
const create = async () => {
const message = validate(newName)
if (message) {
setError(message)
return
}
await run(async () => {
await onCreate(newName.trim(), newColor)
setNewName('')
setNewColor(DEFAULT_WATCHLIST_GROUP_COLOR)
inputRef.current?.focus()
})
}
const rename = async (groupId: string) => {
const message = validate(editingName)
if (message) {
setError(message)
return
}
await run(async () => {
await onRename(groupId, editingName.trim(), editingColor)
setEditingId(null)
})
}
return (
<Modal
onClose={onClose}
labelledBy="watchlist-groups-title"
initialFocusRef={inputRef}
panelClassName="w-[92vw] max-w-md bg-surface border border-border rounded-card shadow-xl"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 id="watchlist-groups-title" className="text-sm font-semibold text-foreground"></h2>
<p className="mt-0.5 text-[11px] text-muted"></p>
</div>
<button type="button" onClick={onClose} className="h-8 w-8 inline-flex items-center justify-center text-muted hover:text-foreground" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-3">
<div className="flex gap-2">
<input
ref={inputRef}
value={newName}
maxLength={24}
onChange={event => setNewName(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') void create() }}
placeholder="新分组名称"
className="h-8 min-w-0 flex-1 rounded-btn border border-border bg-elevated px-3 text-xs text-foreground outline-none focus:border-accent/50"
/>
<button
type="button"
onClick={() => void create()}
disabled={pending}
className="inline-flex h-8 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs text-white disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-2 flex items-start gap-2">
<span className="mt-1 shrink-0 text-[11px] text-muted"></span>
<GroupColorPicker value={newColor} onChange={setNewColor} />
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
</div>
<div className="max-h-[360px] overflow-y-auto border-t border-border px-4">
{groups.length === 0 ? (
<div className="py-10 text-center text-xs text-muted"></div>
) : groups.map(group => {
const color = resolveWatchlistGroupColor(group.color)
return (
<div key={group.id} className="flex min-h-12 items-center gap-2 border-b border-border/60 last:border-0">
<span className={`h-6 w-1 shrink-0 rounded-full ${color.dot}`} />
{editingId === group.id ? (
<div className="min-w-0 flex-1 py-2">
<div className="flex items-center gap-1.5">
<input
value={editingName}
maxLength={24}
onChange={event => setEditingName(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') void rename(group.id) }}
className={`h-7 min-w-0 flex-1 rounded-btn border bg-elevated px-2 text-xs text-foreground outline-none ${resolveWatchlistGroupColor(editingColor).border}`}
autoFocus
/>
<button type="button" disabled={pending} onClick={() => void rename(group.id)} className={`p-1 ${resolveWatchlistGroupColor(editingColor).text}`} title="保存分组">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setEditingId(null)} className="p-1 text-muted hover:text-foreground" title="取消">
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1.5">
<GroupColorPicker value={editingColor} onChange={setEditingColor} />
</div>
</div>
) : deletingId === group.id ? (
<>
<span className="min-w-0 flex-1 text-xs text-secondary">
{group.name}{(counts[group.id] ?? 0) > 0 ? ` ${counts[group.id]} 只股票将回到未分组。` : ''}
</span>
<button
type="button"
disabled={pending}
onClick={() => void run(async () => { await onDelete(group.id); setDeletingId(null) })}
className="rounded px-2 py-1 text-[11px] text-danger bg-danger/10 hover:bg-danger/20 disabled:opacity-50"
>
</button>
<button type="button" onClick={() => setDeletingId(null)} className="p-1 text-muted hover:text-foreground" aria-label="取消删除">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<span className={`min-w-0 flex-1 truncate text-xs ${color.text}`}>{group.name}</span>
<span className="font-mono text-[10px] text-muted tabular-nums">{counts[group.id] ?? 0} </span>
<button
type="button"
onClick={() => {
setEditingId(group.id)
setEditingName(group.name)
setEditingColor(group.color)
setDeletingId(null)
setError('')
}}
className="p-1 text-muted hover:text-accent"
title="编辑分组"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { setDeletingId(group.id); setEditingId(null); setError('') }}
className="p-1 text-muted hover:text-danger"
title="删除分组"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)
})}
</div>
</Modal>
)
}
interface GroupPickerProps {
groups: WatchlistGroup[]
groupId?: string | null
symbol: string
disabled?: boolean
onChange: (symbol: string, groupId: string | null) => void
}
export function WatchlistGroupPicker({ groups, groupId, symbol, disabled, onChange }: GroupPickerProps) {
const group = groups.find(item => item.id === groupId)
const groupName = group?.name ?? '未分组'
const color = resolveWatchlistGroupColor(group?.color)
return (
<label
className={`relative inline-flex h-5 w-5 items-center justify-center rounded border transition-colors ${
group
? `${color.text} ${color.border} ${color.background}`
: 'border-transparent text-muted hover:border-accent/30 hover:text-accent'
} ${disabled ? 'opacity-40' : ''}`}
title={`分组:${groupName}`}
>
<FolderInput className="h-3.5 w-3.5" />
<select
value={groupId ?? ''}
disabled={disabled}
aria-label={`${symbol} 的分组`}
onClick={event => event.stopPropagation()}
onChange={event => onChange(symbol, event.target.value || null)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
>
<option value=""></option>
{groups.map(group => <option key={group.id} value={group.id}>{group.name}</option>)}
</select>
</label>
)
}
@@ -2,13 +2,17 @@ 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 { api, type WatchlistGroupColor, type WatchlistImportCandidate } from '@/lib/api'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
interface Props {
open: boolean
onClose: () => void
groupId?: string | null
groupName?: string
groupColor?: WatchlistGroupColor
}
/** 一次最多排队识别的图片数,避免误选大量文件拖垮小内存机器。 */
@@ -47,7 +51,7 @@ export function mergeImportCandidates(
return [...byCode.values()]
}
export function WatchlistImportDialog({ open, onClose }: Props) {
export function WatchlistImportDialog({ open, onClose, groupId, groupName, groupColor }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const genRef = useRef(0)
@@ -227,7 +231,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
return
}
try {
const data = await batchAdd.mutateAsync(symbols)
const data = await batchAdd.mutateAsync({ symbols, groupId })
toast(`已添加 ${data.added} 只自选`, 'success')
onClose()
} catch {
@@ -244,6 +248,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
: progress
? '识别中…'
: null
const selectedGroupColor = resolveWatchlistGroupColor(groupColor)
return (
<Modal
@@ -256,12 +261,20 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
<h2 id="watchlist-import-title" className="text-sm font-semibold text-foreground">
</h2>
<p className="text-[11px] text-muted mt-0.5">
{ocrBlocked
? 'OCR 引擎不可用'
: '可多选截图,将逐张识别并合并结果后确认添加'}
{provider ? ` · ${provider}` : ''}
</p>
<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>
</div>
<button
type="button"
@@ -16,6 +16,7 @@ import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { MiniIntraday } from '@/components/stock-table/MiniIntraday'
import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import {
DimensionMembersDialog,
dimensionKindForSourceField,
@@ -30,7 +31,8 @@ interface ScreenerTableProps {
activeStrategy: string | null
watchlistSet: Set<string>
onPreview: (symbol: string, name: string) => void
onToggleWatchlist: (symbol: string, inList: boolean) => void
onAddToWatchlist: (symbol: string, groupId: string | null) => void
onRemoveFromWatchlist: (symbol: string) => void
watchlistPending: boolean
/** symbol → 日k 数据,仅当启用日k列时传入 */
klineData?: Record<string, KlineRow[]>
@@ -147,7 +149,7 @@ function renderExtValue(
export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
watchlistSet, onPreview, onAddToWatchlist, onRemoveFromWatchlist, watchlistPending, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
minuteData = {}, intradayChartVisible = true, onToggleIntradayChart,
intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false,
@@ -245,20 +247,27 @@ export function ScreenerTable({
</span>
) : (
<button
type="button"
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
disabled={watchlistPending}
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
disabled:opacity-50
${inWatchlist
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
}`}
title={inWatchlist ? '移出自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
inWatchlist ? (
<button
type="button"
onClick={() => onRemoveFromWatchlist(r.symbol)}
disabled={watchlistPending}
className="shrink-0 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border border-accent/40 bg-accent/10 text-accent transition-colors disabled:opacity-50"
title="移出自选"
aria-label={`${r.symbol} 移出自选`}
>
<Check className="h-3 w-3" />
</button>
) : (
<WatchlistAddMenu
onSelect={groupId => onAddToWatchlist(r.symbol, groupId)}
disabled={watchlistPending}
triggerClassName="shrink-0 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border border-border text-muted transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-50"
ariaLabel={`${r.symbol} 加入自选`}
>
<Plus className="h-3 w-3" />
</WatchlistAddMenu>
)
)}
</div>
</td>
+67 -6
View File
@@ -200,6 +200,12 @@ export interface MinuteKlineRow {
amount: number
}
export interface MinuteKlineSession {
date: string
prev_close: number | null
rows: MinuteKlineRow[]
}
export interface PriceLimitInfo {
rate: number
limit_up: number | null
@@ -233,6 +239,27 @@ export interface WatchlistEntry {
added_at: string
note?: string
name?: string | null
group_id?: string | null
}
export type WatchlistGroupColor =
| 'sky'
| 'blue'
| 'indigo'
| 'violet'
| 'fuchsia'
| 'rose'
| 'orange'
| 'amber'
| 'lime'
| 'emerald'
| 'teal'
| 'cyan'
export interface WatchlistGroup {
id: string
name: string
color: WatchlistGroupColor
}
export interface WatchlistImportCandidate {
@@ -1398,9 +1425,21 @@ export const api = {
source?: 'local' | 'live' | 'none'
asset_type?: 'stock' | 'etf' | 'index'
price_limit?: PriceLimitInfo | null
prev_close?: number | null
}>(
`/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`,
),
klineMinuteRange: (symbol: string, days = 10) =>
request<{
symbol: string
name?: string
asset_type: 'stock' | 'etf' | 'index'
requested_days: number
sessions: MinuteKlineSession[]
source: 'local' | 'none'
}>(
`/api/kline/minute-range?symbol=${encodeURIComponent(symbol)}&days=${days}`,
),
indexList: () => request<{ results: IndexInstrument[]; count: number }>('/api/index/list'),
indexSearch: (q: string, limit = 20) =>
request<{ results: IndexInstrument[] }>(
@@ -1446,10 +1485,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ ...(days ? { days } : {}), ...(extend ? { extend: true } : {}) }),
}),
syncMinuteSingle: (symbol: string) =>
syncMinuteSingle: (symbol: string, days?: number) =>
request<{ status: string; symbol: string; rows: number }>('/api/kline/sync_minute_single', {
method: 'POST',
body: JSON.stringify({ symbol }),
body: JSON.stringify({ symbol, ...(days != null ? { days } : {}) }),
}),
clearMinute: () =>
request<{ status: string; removed: number }>('/api/kline/clear_minute', {
@@ -1472,16 +1511,38 @@ export const api = {
}),
watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'),
watchlistAdd: (symbol: string, note = '') =>
watchlistAdd: (symbol: string, note = '', groupId?: string | null) =>
request<{ symbols: WatchlistEntry[] }>('/api/watchlist', {
method: 'POST',
body: JSON.stringify({ symbol, note }),
body: JSON.stringify({ symbol, note, group_id: groupId ?? null }),
}),
watchlistBatchAdd: (symbols: string[], note = '') =>
watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null) =>
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
method: 'POST',
body: JSON.stringify({ symbols, note }),
body: JSON.stringify({ symbols, note, group_id: groupId ?? null }),
}),
watchlistGroups: () =>
request<{ groups: WatchlistGroup[] }>('/api/watchlist/groups'),
watchlistGroupCreate: (name: string, color: WatchlistGroupColor) =>
request<{ groups: WatchlistGroup[]; group: WatchlistGroup }>('/api/watchlist/groups', {
method: 'POST',
body: JSON.stringify({ name, color }),
}),
watchlistGroupRename: (groupId: string, name: string, color: WatchlistGroupColor) =>
request<{ groups: WatchlistGroup[] }>(
`/api/watchlist/groups/${encodeURIComponent(groupId)}`,
{ method: 'PUT', body: JSON.stringify({ name, color }) },
),
watchlistGroupDelete: (groupId: string) =>
request<{ groups: WatchlistGroup[]; symbols: WatchlistEntry[] }>(
`/api/watchlist/groups/${encodeURIComponent(groupId)}`,
{ method: 'DELETE' },
),
watchlistSetGroup: (symbol: string, groupId: string | null) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}/group`,
{ method: 'PUT', body: JSON.stringify({ group_id: groupId }) },
),
watchlistOcrStatus: () =>
request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'),
watchlistImportImage: (file: File, signal?: AbortSignal, quiet = false) => {
+40
View File
@@ -0,0 +1,40 @@
import type { MinuteKlineRow } from '@/lib/api'
export function formatMinuteTime(datetime: string): string {
const match = datetime.match(/(\d{2}):(\d{2})/)
if (!match) return datetime.slice(11, 16)
const hour = (parseInt(match[1]) + 8) % 24
return `${String(hour).padStart(2, '0')}:${match[2]}`
}
export function computeIntradayAverage(data: MinuteKlineRow[]): number[] {
const result: number[] = []
let amount = 0
let volume = 0
for (const row of data) {
amount += row.amount
volume += row.volume * 100
result.push(volume > 0 ? amount / volume : row.close)
}
return result
}
function generateFullDayTimes(): string[] {
const times: string[] = []
for (let hour = 9; hour <= 11; hour++) {
const startMinute = hour === 9 ? 30 : 0
const endMinute = hour === 11 ? 30 : 59
for (let minute = startMinute; minute <= endMinute; minute++) {
times.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
}
}
for (let hour = 13; hour <= 15; hour++) {
const endMinute = hour === 15 ? 0 : 59
for (let minute = 0; minute <= endMinute; minute++) {
times.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
}
}
return times
}
export const FULL_DAY_TIMES = generateFullDayTimes()
+3
View File
@@ -23,6 +23,7 @@ export const QK = {
// Watchlist
watchlist: ['watchlist'] as const,
watchlistGroups: ['watchlist-groups'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
@@ -61,6 +62,8 @@ export const QK = {
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
klineMinuteRange: (symbol: string, days: number) =>
['kline-minute-range', symbol, days] as const,
indexDaily: (symbol: string, start: string, end: string) =>
['index-daily', symbol, start, end] as const,
indexMinute: (symbol: string, date: string) =>
+3
View File
@@ -36,6 +36,9 @@ export const storage = {
/** 个股日K成交量对比设置 */
stockVolumeCompare: kv<{ enabled: boolean; days: number }>('stock_volume_compare'),
/** 个股详情多日分时周期 */
stockPreviewIntradayDays: kv<number>('stock_preview_intraday_days'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
+8 -2
View File
@@ -29,11 +29,17 @@ export function useUpdateQuoteInterval() {
})
}
/** 批量添加自选 — Screener / Intraday / 截图导入 共用 */
interface WatchlistBatchAddInput {
symbols: string[]
groupId?: string | null
}
/** 批量添加自选 — Screener / 截图导入共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
mutationFn: ({ symbols, groupId }: WatchlistBatchAddInput) =>
api.watchlistBatchAdd(symbols, '', groupId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
// 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam],
@@ -0,0 +1,33 @@
import type { WatchlistGroupColor } from '@/lib/api'
export interface WatchlistGroupColorOption {
id: WatchlistGroupColor
label: string
text: string
border: string
background: string
dot: string
ring: string
}
export const DEFAULT_WATCHLIST_GROUP_COLOR: WatchlistGroupColor = 'sky'
export const WATCHLIST_GROUP_COLORS: readonly WatchlistGroupColorOption[] = [
{ id: 'sky', label: '天蓝', text: 'text-sky-400', border: 'border-sky-400/40', background: 'bg-sky-400/10', dot: 'bg-sky-400', ring: 'ring-sky-400/60' },
{ id: 'blue', label: '蓝色', text: 'text-blue-400', border: 'border-blue-400/40', background: 'bg-blue-400/10', dot: 'bg-blue-400', ring: 'ring-blue-400/60' },
{ id: 'indigo', label: '靛蓝', text: 'text-indigo-400', border: 'border-indigo-400/40', background: 'bg-indigo-400/10', dot: 'bg-indigo-400', ring: 'ring-indigo-400/60' },
{ id: 'violet', label: '紫色', text: 'text-violet-400', border: 'border-violet-400/40', background: 'bg-violet-400/10', dot: 'bg-violet-400', ring: 'ring-violet-400/60' },
{ id: 'fuchsia', label: '品红', text: 'text-fuchsia-400', border: 'border-fuchsia-400/40', background: 'bg-fuchsia-400/10', dot: 'bg-fuchsia-400', ring: 'ring-fuchsia-400/60' },
{ id: 'rose', label: '玫红', text: 'text-rose-400', border: 'border-rose-400/40', background: 'bg-rose-400/10', dot: 'bg-rose-400', ring: 'ring-rose-400/60' },
{ id: 'orange', label: '橙色', text: 'text-orange-400', border: 'border-orange-400/40', background: 'bg-orange-400/10', dot: 'bg-orange-400', ring: 'ring-orange-400/60' },
{ id: 'amber', label: '金色', text: 'text-amber-400', border: 'border-amber-400/40', background: 'bg-amber-400/10', dot: 'bg-amber-400', ring: 'ring-amber-400/60' },
{ id: 'lime', label: '青柠', text: 'text-lime-400', border: 'border-lime-400/40', background: 'bg-lime-400/10', dot: 'bg-lime-400', ring: 'ring-lime-400/60' },
{ id: 'emerald', label: '绿色', text: 'text-emerald-400', border: 'border-emerald-400/40', background: 'bg-emerald-400/10', dot: 'bg-emerald-400', ring: 'ring-emerald-400/60' },
{ id: 'teal', label: '墨绿', text: 'text-teal-400', border: 'border-teal-400/40', background: 'bg-teal-400/10', dot: 'bg-teal-400', ring: 'ring-teal-400/60' },
{ id: 'cyan', label: '青色', text: 'text-cyan-400', border: 'border-cyan-400/40', background: 'bg-cyan-400/10', dot: 'bg-cyan-400', ring: 'ring-cyan-400/60' },
]
export function resolveWatchlistGroupColor(color?: string | null): WatchlistGroupColorOption {
return WATCHLIST_GROUP_COLORS.find(option => option.id === color)
?? WATCHLIST_GROUP_COLORS[0]
}
+23 -9
View File
@@ -14,6 +14,7 @@ import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import { useStrategyPool } from '@/lib/useStrategyPool'
import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard'
import { ScreenerTable } from '@/components/screener/ScreenerTable'
@@ -509,8 +510,17 @@ export function Screener() {
// 单只股票加入/移出自选
const toggleWatchlist = useMutation({
mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) =>
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
mutationFn: ({
symbol,
action,
groupId,
}: {
symbol: string
action: 'add' | 'remove'
groupId?: string | null
}) => action === 'remove'
? api.watchlistRemove(symbol)
: api.watchlistAdd(symbol, '', groupId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
@@ -567,10 +577,10 @@ export function Screener() {
}
}
const handleBatchAdd = () => {
const handleBatchAdd = (groupId: string | null) => {
if (!displayRows.length) return
const symbols = displayRows.map((r: any) => r.symbol)
batchAdd.mutate(symbols, {
batchAdd.mutate({ symbols, groupId }, {
onSuccess: (data) => {
setBatchMsg(`已添加 ${data.added} 只到自选`)
setTimeout(() => setBatchMsg(''), 3000)
@@ -815,16 +825,19 @@ export function Screener() {
</div>
)}
{displayRows.length > 0 && (
<button
onClick={handleBatchAdd}
<WatchlistAddMenu
onSelect={handleBatchAdd}
disabled={batchAdd.isPending}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
align="right"
title="批量加自选"
ariaLabel="批量加入自选"
triggerClassName="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
border border-accent/40 bg-accent/10 text-accent text-xs font-medium
hover:bg-accent/20 disabled:opacity-50 transition-colors duration-150 cursor-pointer"
>
<Star className="h-3 w-3" />
{batchAdd.isPending ? '添加中…' : '批量加自选'}
</button>
</WatchlistAddMenu>
)}
<button
onClick={() => setCustomizerOpen(true)}
@@ -895,7 +908,8 @@ export function Screener() {
activeStrategy={activeStrategy}
watchlistSet={watchlistSet}
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })}
onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })}
watchlistPending={toggleWatchlist.isPending}
klineData={klineData}
dailyKChartVisible={dailyKChartVisible}
+187 -38
View File
@@ -2,8 +2,8 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
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, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
@@ -16,6 +16,12 @@ import {
type DimensionMembersTarget,
} from '@/components/DimensionMembersDialog'
import { WatchlistImportDialog } from '@/components/WatchlistImportDialog'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import {
WatchlistGroupBar,
WatchlistGroupPicker,
type WatchlistGroupFilter,
} from '@/components/WatchlistGroups'
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable'
@@ -212,10 +218,14 @@ function StockSearchBox({
onPreview,
existingSymbols,
onAdd,
preferredGroupId,
addPending,
}: {
onPreview: (symbol: string, name: string) => void
existingSymbols: string[]
onAdd: (symbol: string) => void
onAdd: (symbol: string, groupId: string | null) => void
preferredGroupId: string | null
addPending: boolean
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@@ -234,6 +244,7 @@ function StockSearchBox({
useEffect(() => {
function handleClick(e: MouseEvent) {
if (e.target instanceof Element && e.target.closest('[data-watchlist-group-menu]')) return
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
@@ -319,19 +330,26 @@ function StockSearchBox({
)
})()}
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); onAdd(r.symbol) }}
disabled={inWatchlist}
className={`shrink-0 p-1 rounded transition-colors ${
inWatchlist
? 'text-accent bg-accent/10 cursor-default'
: 'text-muted hover:text-accent hover:bg-accent/10'
}`}
title={inWatchlist ? '已加自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
</button>
{inWatchlist ? (
<button
type="button"
disabled
className="shrink-0 rounded p-1 text-accent bg-accent/10 cursor-default"
title="已加自选"
aria-label="已加自选"
>
<Check className="h-3.5 w-3.5" />
</button>
) : (
<WatchlistAddMenu
onSelect={groupId => onAdd(r.symbol, groupId)}
preferredGroupId={preferredGroupId}
disabled={addPending}
triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</WatchlistAddMenu>
)}
</div>
)
})}
@@ -402,6 +420,9 @@ const StockCard = React.memo(function StockCard({
onToggleExpand,
onDimensionClick,
isMonitored,
groups,
onGroupChange,
groupChangePending,
}: {
r: any
candleRows: KlineRow[]
@@ -416,6 +437,9 @@ const StockCard = React.memo(function StockCard({
onToggleExpand: (key: string) => void
onDimensionClick: (target: DimensionMembersTarget) => void
isMonitored?: boolean
groups: WatchlistGroup[]
onGroupChange: (symbol: string, groupId: string | null) => void
groupChangePending: boolean
}) {
const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close
@@ -444,7 +468,7 @@ const StockCard = React.memo(function StockCard({
{/* 左侧彩色指示条 */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-lg ${barColor}`} />
{/* 删除按钮 / 确认区 */}
{/* 分组与删除入口 */}
<div className="absolute top-1.5 right-1.5 z-10">
{isConfirming ? (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
@@ -459,20 +483,29 @@ const StockCard = React.memo(function StockCard({
</button>
</div>
) : (
<button
onClick={e => { e.stopPropagation(); onRequestRemove(r.symbol) }}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all duration-150 p-0.5 rounded hover:bg-elevated"
aria-label="移除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-0.5" onClick={e => e.stopPropagation()}>
<WatchlistGroupPicker
groups={groups}
groupId={r.group_id}
symbol={r.symbol}
disabled={groupChangePending}
onChange={onGroupChange}
/>
<button
onClick={() => onRequestRemove(r.symbol)}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all duration-150 p-0.5 rounded hover:bg-elevated"
aria-label="移除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* 卡片内容 */}
<div className="pl-4 pr-2.5 pt-2.5 pb-0">
{/* 第一行: 代码 + 名称 + 板块标识 */}
<div className="flex items-center gap-1.5 min-w-0 mb-2">
<div className="flex items-center gap-1.5 min-w-0 mb-2 pr-8">
<span className="shrink-0 font-mono text-foreground text-xs tracking-wide">
{r.symbol}
</span>
@@ -584,6 +617,7 @@ export function Watchlist() {
const [columns, setColumns] = useState<ColumnConfig[]>([...BUILTIN_COLUMNS])
const [customizerOpen, setCustomizerOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<WatchlistGroupFilter>('all')
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
const [ocrInstallHint, setOcrInstallHint] = useState('')
const columnsLoaded = useRef(false)
@@ -696,6 +730,26 @@ export function Watchlist() {
queryFn: api.watchlistList,
})
const groupList = useQuery({
queryKey: QK.watchlistGroups,
queryFn: api.watchlistGroups,
})
const groups = groupList.data?.groups ?? []
const activeGroupId = selectedGroup === 'all' || selectedGroup === 'ungrouped'
? null
: selectedGroup
useEffect(() => {
if (
selectedGroup !== 'all'
&& selectedGroup !== 'ungrouped'
&& groupList.isSuccess
&& !groups.some(group => group.id === selectedGroup)
) {
setSelectedGroup('all')
}
}, [groupList.isSuccess, groups, selectedGroup])
// enriched 数据 — 传入 ext_columns 参数
const enriched = useQuery({
queryKey: QK.watchlistEnriched(extColumnsParam),
@@ -743,7 +797,8 @@ export function Watchlist() {
const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
const addMutation = useMutation({
mutationFn: (sym: string) => api.watchlistAdd(sym),
mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) =>
api.watchlistAdd(symbol, '', groupId),
onSuccess: (data) => {
qc.setQueryData(QK.watchlist, data)
qc.invalidateQueries({ queryKey: QK.watchlist })
@@ -791,6 +846,36 @@ export function Watchlist() {
},
})
const createGroup = useMutation({
mutationFn: ({ name, color }: { name: string; color: WatchlistGroupColor }) =>
api.watchlistGroupCreate(name, color),
onSuccess: data => {
qc.setQueryData(QK.watchlistGroups, { groups: data.groups })
setSelectedGroup(data.group.id)
},
})
const renameGroup = useMutation({
mutationFn: ({ groupId, name, color }: { groupId: string; name: string; color: WatchlistGroupColor }) =>
api.watchlistGroupRename(groupId, name, color),
onSuccess: data => qc.setQueryData(QK.watchlistGroups, data),
})
const deleteGroup = useMutation({
mutationFn: (groupId: string) => api.watchlistGroupDelete(groupId),
onSuccess: (data, groupId) => {
qc.setQueryData(QK.watchlistGroups, { groups: data.groups })
qc.setQueryData(QK.watchlist, { symbols: data.symbols })
if (selectedGroup === groupId) setSelectedGroup('all')
},
})
const assignGroup = useMutation({
mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) =>
api.watchlistSetGroup(symbol, groupId),
onSuccess: data => qc.setQueryData(QK.watchlist, data),
})
// 二次确认状态
const [confirmClear, setConfirmClear] = useState(false)
const [confirmRemove, setConfirmRemove] = useState<string | null>(null)
@@ -804,9 +889,35 @@ export function Watchlist() {
}, [remove])
const handleCardCancelRemove = useCallback(() => setConfirmRemove(null), [])
const handleCardRequestRemove = useCallback((sym: string) => setConfirmRemove(sym), [])
const handleGroupChange = useCallback((symbol: string, groupId: string | null) => {
assignGroup.mutate({ symbol, groupId })
}, [assignGroup])
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const listEntries = list.data?.symbols ?? []
const allSymbols = listEntries.map(s => s.symbol)
const rows = enriched.data?.rows ?? []
const groupBySymbol = useMemo(
() => new Map(listEntries.map(entry => [entry.symbol, entry.group_id ?? null])),
[listEntries],
)
const groupCounts = useMemo(() => {
const counts: Record<string, number> = { ungrouped: 0 }
for (const entry of listEntries) {
const groupId = entry.group_id ?? 'ungrouped'
counts[groupId] = (counts[groupId] ?? 0) + 1
}
return counts
}, [listEntries])
const rowsInSelectedGroup = useMemo(() => {
const rowsWithGroup = rows.map(row => ({ ...row, group_id: groupBySymbol.get(row.symbol) ?? null }))
if (selectedGroup === 'all') return rowsWithGroup
if (selectedGroup === 'ungrouped') return rowsWithGroup.filter(row => row.group_id == null)
return rowsWithGroup.filter(row => row.group_id === 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') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
@@ -885,7 +996,7 @@ export function Watchlist() {
// 筛选 + 排序
const filteredRows = useMemo(() => {
// 板块筛选(全选时跳过)
let result = rows
let result = rowsInSelectedGroup
if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) {
result = result.filter(r => {
// 非股票 (指数/ETF) 无板块语义, 不受板块筛选影响 (顺带修复 ETF 行被误过滤)
@@ -914,7 +1025,7 @@ export function Watchlist() {
})
}
return result
}, [rows, filters, columns, boardFilter])
}, [rowsInSelectedGroup, filters, columns, boardFilter])
const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length
const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length
@@ -961,8 +1072,8 @@ export function Watchlist() {
)
// "被筛选条件隐藏" 的个股数: 后端返回的行数 vs 经过前端筛选后的行数.
// rows.length 是后端实际返回 (含 pending 行), 减去 sortedRows (筛选后) 才是真正的筛选隐藏.
const hiddenCount = Math.max(0, rows.length - sortedRows.length)
// 分组切换不计入筛选隐藏,只比较当前分组内的数据。
const hiddenCount = Math.max(0, rowsInSelectedGroup.length - sortedRows.length)
const renderStockCard = (r: any) => (
<StockCard
@@ -980,6 +1091,9 @@ export function Watchlist() {
onToggleExpand={handleToggleExpand}
onDimensionClick={setDimensionTarget}
isMonitored={monitoredSymbols.has(r.symbol)}
groups={groups}
onGroupChange={handleGroupChange}
groupChangePending={assignGroup.isPending}
/>
)
@@ -993,7 +1107,7 @@ export function Watchlist() {
<span className="inline-flex items-baseline gap-0.5 px-2 py-0.5 rounded-md bg-elevated/70 text-[11px]">
<span className="font-mono font-semibold text-secondary tabular-nums">{sortedRows.length}</span>
<span className="text-muted/50">/</span>
<span className="font-mono text-muted tabular-nums">{allSymbols.length}</span>
<span className="font-mono text-muted tabular-nums">{rowsInSelectedGroup.length}</span>
<span className="text-muted/60 ml-0.5"></span>
</span>
{/* 数据未就绪提示: 自选了但 enriched 缓存未覆盖 (新股/冷门/新用户未同步), 指标全为 null */}
@@ -1045,7 +1159,9 @@ export function Watchlist() {
<StockSearchBox
onPreview={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name) }}
existingSymbols={allSymbols as string[]}
onAdd={(sym) => addMutation.mutate(sym)}
onAdd={(symbol, groupId) => addMutation.mutate({ symbol, groupId })}
preferredGroupId={activeGroupId}
addPending={addMutation.isPending}
/>
<button
onClick={() => {
@@ -1104,6 +1220,17 @@ export function Watchlist() {
}
/>
<WatchlistGroupBar
groups={groups}
counts={groupCounts}
selected={selectedGroup}
total={allSymbols.length}
onSelect={setSelectedGroup}
onCreate={(name, color) => createGroup.mutateAsync({ name, color }).then(() => undefined)}
onRename={(groupId, name, color) => renameGroup.mutateAsync({ groupId, name, color }).then(() => undefined)}
onDelete={groupId => deleteGroup.mutateAsync(groupId).then(() => undefined)}
/>
{/* 筛选栏 */}
{filterOpen && (
<div className="px-5 py-2 border-b border-border bg-surface/50 max-h-[184px] overflow-y-auto">
@@ -1180,15 +1307,24 @@ export function Watchlist() {
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-5 py-3">
{/* 列表 */}
{list.isLoading && <div className="text-sm text-muted"></div>}
{list.isError && <div className="text-sm text-danger"></div>}
{allSymbols.length === 0 ? (
{watchlistContentLoading ? (
<div className="text-sm text-muted"></div>
) : list.isError ? (
<div className="text-sm text-danger"></div>
) : enriched.isError ? (
<div className="text-sm text-danger"></div>
) : allSymbols.length === 0 ? (
<EmptyState
icon={Star}
title="自选股为空"
hint="点击右上角搜索添加标的,或点击图片图标从券商自选截图批量导入。"
/>
) : rowsInSelectedGroup.length === 0 ? (
<EmptyState
icon={FolderOpen}
title="该分组暂无标的"
hint="使用右上角搜索添加,或通过股票旁的分组按钮移入当前分组。"
/>
) : viewMode === 'table' ? (
<StockDataTable
columns={visibleColumns}
@@ -1314,6 +1450,13 @@ export function Watchlist() {
</div>
) : (
<div className="flex items-center gap-1">
<WatchlistGroupPicker
groups={groups}
groupId={r.group_id}
symbol={r.symbol}
disabled={assignGroup.isPending}
onChange={handleGroupChange}
/>
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
@@ -1513,7 +1656,13 @@ export function Watchlist() {
}}
/>
<WatchlistImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
<WatchlistImportDialog
open={importOpen}
onClose={() => setImportOpen(false)}
groupId={activeGroupId}
groupName={activeGroup?.name}
groupColor={activeGroup?.color}
/>
</div>
)
}
@@ -29,6 +29,7 @@ import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
import { TradeKlineModal } from './components/TradeKlineModal'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
const monthsAgo = (months: number) => {
@@ -774,6 +775,15 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
queryFn: () => api.watchlistList(),
staleTime: 30_000,
})
const watchlistEntries = watchlist.data?.symbols ?? []
const watchlistCounts = useMemo(() => {
const counts: Record<string, number> = { ungrouped: 0 }
for (const entry of watchlistEntries) {
const groupId = entry.group_id ?? 'ungrouped'
counts[groupId] = (counts[groupId] ?? 0) + 1
}
return counts
}, [watchlistEntries])
useEffect(() => {
if (results.length === 0) return
@@ -802,9 +812,11 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
setOpen(false)
}
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
// 一键导入自选: 合并去重, 顺带回填股票名
const importFromWatchlist = () => {
const entries = watchlist.data?.symbols ?? []
// 按分组导入自选: 合并去重, 顺带回填股票名
const importFromWatchlist = (groupId: string | null) => {
const entries = groupId === 'all'
? watchlistEntries
: watchlistEntries.filter(entry => (entry.group_id ?? null) === groupId)
if (entries.length === 0) return
setSymbolNames(prev => {
const next = { ...prev }
@@ -813,7 +825,7 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
})
setSymbols([...symbols, ...entries.map(e => e.symbol)])
}
const watchlistCount = watchlist.data?.symbols?.length ?? 0
const watchlistCount = watchlistEntries.length
return (
<div className="space-y-2" ref={ref}>
@@ -861,16 +873,22 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
{symbols.length === 0 ? '全市场' : `${symbols.length}`}
</span>
<button
type="button"
onClick={importFromWatchlist}
<WatchlistGroupMenu
onSelect={importFromWatchlist}
disabled={watchlist.isLoading || watchlistCount === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title="把自选列表的个股加入回测范围"
includeAll
counts={watchlistCounts}
total={watchlistCount}
disableEmpty
menuLabel="导入自选分组"
align="right"
triggerClassName="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title="选择自选分组并加入回测范围"
ariaLabel="从自选分组导入回测范围"
>
<ListPlus className="h-3 w-3" />
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
</button>
</WatchlistGroupMenu>
<button
type="button"
onClick={() => setSymbols([])}