mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat: add watchlist groups and multi-day intraday view
This commit is contained in:
+127
-2
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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},
|
||||
))
|
||||
@@ -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"},
|
||||
]
|
||||
Generated
+12
-1
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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="加入到自选分组" />
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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([])}
|
||||
|
||||
Reference in New Issue
Block a user