mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: v0.2 UI 重构 + 自选分组侧边栏 + 扩展数据时间窗口 + 个股对话框优化
- 侧边栏美化:品牌改名/菜单 active 指示条/收起展开/底部精简 - 数据源+AI 状态卡:名称替换固定文字/档位 tag 条件显示 - 设置页重构:account 合入数据源 tab/数据源置顶/收起展开菜单 - 自选分组侧边栏:二级子菜单/分组跳转同步/清空分组 - 个股对话框:tab 移顶栏/分时关闭/放大全屏/操作按钮归位 - 扩展数据:定时拉取时间窗口(time_window_start/end) - 版本号: 0.1.88 → 0.2.1
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.88"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -79,6 +79,8 @@ class PullConfigReq(BaseModel):
|
||||
field_map: dict[str, str] | None = None # external → internal field name
|
||||
schedule_minutes: int = Field(1440, ge=1)
|
||||
enabled: bool = False
|
||||
time_window_start: str | None = None # "HH:MM", None=不限
|
||||
time_window_end: str | None = None # "HH:MM", None=不限
|
||||
|
||||
|
||||
class DetectUrlReq(BaseModel):
|
||||
@@ -608,6 +610,8 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
field_map=body.field_map,
|
||||
schedule_minutes=body.schedule_minutes,
|
||||
enabled=body.enabled,
|
||||
time_window_start=body.time_window_start,
|
||||
time_window_end=body.time_window_end,
|
||||
last_run=old_pull.last_run if old_pull else None,
|
||||
last_status=old_pull.last_status if old_pull else None,
|
||||
last_message=old_pull.last_message if old_pull else None,
|
||||
|
||||
@@ -405,6 +405,7 @@ def get_preferences() -> dict:
|
||||
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
|
||||
"realtime_allowed": _realtime_allowed(),
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"watchlist_groups_in_nav": preferences.get_watchlist_groups_in_nav(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
"minute_sync_segment_days": preferences.get_minute_sync_segment_days(),
|
||||
@@ -776,6 +777,18 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
||||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||||
|
||||
|
||||
class WatchlistGroupsInNavPrefs(BaseModel):
|
||||
watchlist_groups_in_nav: bool
|
||||
|
||||
|
||||
@router.put("/preferences/watchlist-groups-in-nav")
|
||||
def update_watchlist_groups_in_nav(req: WatchlistGroupsInNavPrefs) -> dict:
|
||||
"""保存自选分组是否显示在侧边栏开关。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"watchlist_groups_in_nav": req.watchlist_groups_in_nav})
|
||||
return {"watchlist_groups_in_nav": req.watchlist_groups_in_nav}
|
||||
|
||||
|
||||
class RealtimeMonitorConfigIn(BaseModel):
|
||||
sse_refresh_pages: dict[str, bool] | None = None
|
||||
strategy_monitor_enabled: bool | None = None
|
||||
|
||||
@@ -125,6 +125,16 @@ def delete_group(group_id: str, request: Request):
|
||||
return {"groups": groups, "symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/clear")
|
||||
def clear_group(group_id: str, request: Request):
|
||||
"""清空分组成员:把该分组内所有股票转为未分组,保留分组定义。"""
|
||||
try:
|
||||
rows = watchlist.clear_group(group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.get("/ocr-status")
|
||||
def ocr_status():
|
||||
"""当前 OCR 引擎是否可用(前端可据此提示安装依赖)。"""
|
||||
|
||||
@@ -39,7 +39,7 @@ class PullConfig:
|
||||
"url", "method", "headers", "body", "response_path",
|
||||
"field_map", "schedule_minutes", "enabled",
|
||||
"last_run", "last_status", "last_message", "last_rows",
|
||||
"next_run",
|
||||
"next_run", "time_window_start", "time_window_end",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -57,6 +57,8 @@ class PullConfig:
|
||||
last_message: str | None = None,
|
||||
last_rows: int | None = None,
|
||||
next_run: str | None = None,
|
||||
time_window_start: str | None = None,
|
||||
time_window_end: str | None = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.method = method # GET | POST
|
||||
@@ -71,6 +73,8 @@ class PullConfig:
|
||||
self.last_message = last_message
|
||||
self.last_rows = last_rows
|
||||
self.next_run = next_run # 下次预计运行 (ISO, 调度器写入)
|
||||
self.time_window_start = time_window_start # 每日拉取窗口起始 "HH:MM", None=不限
|
||||
self.time_window_end = time_window_end # 每日拉取窗口结束 "HH:MM", None=不限
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -87,6 +91,8 @@ class PullConfig:
|
||||
"last_message": self.last_message,
|
||||
"last_rows": self.last_rows,
|
||||
"next_run": self.next_run,
|
||||
"time_window_start": self.time_window_start,
|
||||
"time_window_end": self.time_window_end,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -107,6 +113,8 @@ class PullConfig:
|
||||
last_message=d.get("last_message"),
|
||||
last_rows=d.get("last_rows"),
|
||||
next_run=d.get("next_run"),
|
||||
time_window_start=d.get("time_window_start"),
|
||||
time_window_end=d.get("time_window_end"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,21 @@ from app.services.ext_data import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_time_window(start: str | None, end: str | None) -> bool:
|
||||
"""检查当前本地时间是否在每日时间窗口内。
|
||||
|
||||
start/end 为 "HH:MM" 格式。两者都为 None 时不限制(返回 True)。
|
||||
支持跨午夜窗口(如 22:00-02:00)。
|
||||
"""
|
||||
if not start or not end:
|
||||
return True
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
if start <= end:
|
||||
return start <= now < end
|
||||
# 跨午夜: 如 22:00-02:00
|
||||
return now >= start or now < end
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 响应解析
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,6 +268,17 @@ class PullScheduler:
|
||||
break
|
||||
pull = fresh.pull
|
||||
|
||||
# 时间窗口检查: 不在窗口内则跳过本次拉取
|
||||
if not _in_time_window(pull.time_window_start, pull.time_window_end):
|
||||
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh.pull.last_status = "skipped"
|
||||
fresh.pull.last_message = "不在拉取时间窗口内"
|
||||
store.upsert(fresh)
|
||||
logger.info("PullScheduler: %s skipped (outside time window)", config.id)
|
||||
interval = max(pull.schedule_minutes * 60, 60)
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
|
||||
try:
|
||||
n, d = await fetch_and_ingest(fresh, self._data_dir)
|
||||
|
||||
@@ -49,6 +49,11 @@ def get_indices_nav_pinned() -> bool:
|
||||
return load().get("indices_nav_pinned", True)
|
||||
|
||||
|
||||
def get_watchlist_groups_in_nav() -> bool:
|
||||
"""自选分组是否显示在侧边栏(可展开二级子菜单)。默认 False。"""
|
||||
return load().get("watchlist_groups_in_nav", False)
|
||||
|
||||
|
||||
def get_realtime_quote_interval() -> float:
|
||||
return load().get("realtime_quote_interval", 6.0)
|
||||
|
||||
|
||||
@@ -281,6 +281,22 @@ def set_group(symbol: str, group_id: str | None) -> list[dict]:
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def clear_group(group_id: str) -> list[dict]:
|
||||
"""清空分组成员:把该分组内所有条目 group_id 置 null(变未分组),保留分组定义。"""
|
||||
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")
|
||||
)
|
||||
_write_entries(df)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
|
||||
"""拉取实时行情。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.88"
|
||||
version = "0.2.1"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -112,3 +112,42 @@ def test_historical_groups_default_to_sky(monkeypatch, tmp_path):
|
||||
{"id": "legacy", "name": "旧分组", "color": "sky"},
|
||||
{"id": "invalid", "name": "未知颜色", "color": "sky"},
|
||||
]
|
||||
|
||||
|
||||
def test_clear_group_moves_members_to_ungrouped(monkeypatch, tmp_path):
|
||||
"""清空分组:成员变未分组,分组定义保留。"""
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, group = watchlist.create_group("芯片")
|
||||
watchlist.add("600000.SH", group_id=group["id"])
|
||||
watchlist.add("000001.SZ", group_id=group["id"])
|
||||
watchlist.add("300750.SZ") # 不在任何分组
|
||||
|
||||
rows = watchlist.clear_group(group["id"])
|
||||
# 3 只都还在,group_id 全部为 None
|
||||
assert len(rows) == 3
|
||||
assert all(r["group_id"] is None for r in rows)
|
||||
# 分组定义仍在
|
||||
assert any(g["id"] == group["id"] for g in watchlist.list_groups())
|
||||
|
||||
# 清空不存在的分组 → KeyError
|
||||
with pytest.raises(KeyError):
|
||||
watchlist.clear_group("missing")
|
||||
|
||||
|
||||
def test_clear_group_api(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"]
|
||||
watchlist.add("600000.SH", group_id=group_id)
|
||||
watchlist.add("000001.SZ", group_id=group_id)
|
||||
|
||||
result = watchlist_api.clear_group(group_id, request)
|
||||
assert all(s["group_id"] is None for s in result["symbols"])
|
||||
|
||||
# 不存在的分组 → 404
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
watchlist_api.clear_group("missing", request)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
Generated
+1841
-1841
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.88",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+279
-183
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, Suspense } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useQuoteStream, useQuoteStreamStatus } from '@/lib/useQuoteStream'
|
||||
@@ -43,14 +43,19 @@ import {
|
||||
CheckCircle2,
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Moon,
|
||||
X,
|
||||
WifiOff,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||
import { toggleTheme, useTheme } from '@/lib/theme'
|
||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
||||
|
||||
@@ -78,7 +83,7 @@ const nav = [
|
||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
||||
{ to: '/regime', label: '市场环境', icon: Gauge, badge: 'beta' },
|
||||
{ to: '/regime', label: '市场环境', icon: Gauge },
|
||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
@@ -162,115 +167,103 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
|
||||
}
|
||||
|
||||
// ===== 档位卡片 =====
|
||||
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
function TierBadge({ label, hasKey, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) {
|
||||
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
||||
const isNone = base === 'none'
|
||||
|
||||
const tierConfig: Record<string, {
|
||||
desc: string
|
||||
tagBg: React.CSSProperties
|
||||
dotStyle: React.CSSProperties
|
||||
tagBg: React.CSSProperties
|
||||
labelTextStyle: React.CSSProperties
|
||||
}> = {
|
||||
none: {
|
||||
desc: '未配置 Key · 仅历史日K',
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
dotStyle: { background: '#52525b' },
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '基础日K · 自选实时',
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
dotStyle: { background: '#71717a' },
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
labelTextStyle: { color: '#a1a1aa' },
|
||||
},
|
||||
starter: {
|
||||
desc: '批量同步 · 行情池',
|
||||
tagBg: { background: 'rgba(59,130,246,0.2)' },
|
||||
dotStyle: { background: '#3b82f6' },
|
||||
tagBg: { background: 'rgba(59,130,246,0.2)' },
|
||||
labelTextStyle: { color: '#60a5fa' },
|
||||
},
|
||||
pro: {
|
||||
desc: '分钟K · 实时行情 · 盘口',
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
|
||||
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
},
|
||||
expert: {
|
||||
desc: 'WebSocket · 财务数据',
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
||||
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
},
|
||||
}
|
||||
|
||||
const t = tierConfig[base] || tierConfig.none
|
||||
// none 档显示英文「None」,无 label 时也显示「None」
|
||||
const displayLabel = isNone ? 'None' : (label || 'None')
|
||||
const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to="/settings?tab=account"
|
||||
className="mt-2.5 group block -mx-2.5"
|
||||
title="API 设置"
|
||||
to="/settings?tab=data-sources"
|
||||
className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70"
|
||||
title={`数据源 · ${providerName} — ${descText}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-lg border border-blue-400/20 bg-gradient-to-br from-blue-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-blue-400/35 hover:from-blue-500/[0.16]">
|
||||
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-blue-500/10 blur-2xl" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-400/10 text-blue-300 ring-1 ring-blue-400/20">
|
||||
<Key className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">TickFlow</span>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
|
||||
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
|
||||
</span>
|
||||
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-accent/50 transition-colors group-hover:bg-accent"
|
||||
style={base === 'expert' ? { background: 'linear-gradient(180deg, #60a5fa, #c084fc, #fbbf24)' } : undefined}
|
||||
/>
|
||||
<Key className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-accent transition-colors" />
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors">
|
||||
{providerName || '数据源'}
|
||||
</span>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full shrink-0"
|
||||
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
|
||||
/>
|
||||
{isTickflow && (
|
||||
<span
|
||||
className="ml-auto inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold font-mono leading-none shrink-0"
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
|
||||
const descText = configured ? (model || '已接入模型') : '接入策略生成模型'
|
||||
return (
|
||||
<NavLink
|
||||
to="/settings?tab=ai"
|
||||
className="mt-2 group block -mx-2.5"
|
||||
title="AI 配置"
|
||||
className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70"
|
||||
title={`AI 配置 — ${descText}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-lg border border-purple-400/20 bg-gradient-to-br from-purple-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-purple-400/35 hover:from-purple-500/[0.16]">
|
||||
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-purple-500/10 blur-2xl" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-400/10 text-purple-300 ring-1 ring-purple-400/20">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">AI 配置</span>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${configured ? 'bg-bear' : 'bg-warning'}`} />
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
|
||||
{configured ? (model || '已接入模型') : '接入策略生成模型'}
|
||||
</div>
|
||||
</div>
|
||||
<Settings className="h-3 w-3 text-muted group-hover:text-purple-300 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-purple-400/50 transition-colors group-hover:bg-purple-400" />
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-purple-400 transition-colors" />
|
||||
{configured ? (
|
||||
<span className="truncate text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors">
|
||||
{model || '已接入模型'}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] text-secondary group-hover:text-foreground transition-colors">AI 配置</span>
|
||||
<span className="ml-auto text-[11px] font-mono leading-none text-muted">未配置</span>
|
||||
</>
|
||||
)}
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${configured ? 'bg-bear' : 'bg-warning'}`} />
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -294,6 +287,19 @@ export function Layout() {
|
||||
queryFn: api.analysisMenus,
|
||||
})
|
||||
|
||||
// 自选分组 — 仅当用户开启「显示在侧边栏」时拉取
|
||||
const groupsInNav = prefs?.watchlist_groups_in_nav ?? false
|
||||
const location = useLocation()
|
||||
const { data: watchlistGroupsData } = useQuery({
|
||||
queryKey: QK.watchlistGroups,
|
||||
queryFn: api.watchlistGroups,
|
||||
enabled: groupsInNav,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const watchlistGroups = watchlistGroupsData?.groups ?? []
|
||||
// 自选二级菜单展开状态 — 默认当前在自选页时展开
|
||||
const [watchlistNavExpanded, setWatchlistNavExpanded] = useState(location.pathname === '/watchlist')
|
||||
|
||||
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
|
||||
const { data: pipelineJobs } = useQuery({
|
||||
queryKey: QK.pipelineJobs,
|
||||
@@ -324,6 +330,17 @@ export function Layout() {
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
|
||||
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
||||
// 侧边栏收起状态 — 持久化到 localStorage
|
||||
const [navCollapsed, setNavCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem('tf-nav-collapsed') === '1' } catch { return false }
|
||||
})
|
||||
const toggleNavCollapsed = () => {
|
||||
setNavCollapsed(prev => {
|
||||
const next = !prev
|
||||
try { localStorage.setItem('tf-nav-collapsed', next ? '1' : '0') } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
|
||||
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
|
||||
@@ -356,14 +373,11 @@ export function Layout() {
|
||||
? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider)
|
||||
: null
|
||||
|
||||
// 当前主数据源 (用于菜单底部状态条)
|
||||
// 当前主数据源 (用于侧边栏数据源状态卡)
|
||||
const activeProvider = prefs?.daily_data_provider || 'tickflow'
|
||||
const activeProviderName = activeProvider === 'tickflow'
|
||||
? 'TickFlow'
|
||||
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
|
||||
const activeProviderDatasets = activeProvider === 'tickflow'
|
||||
? ['daily', 'adj_factor', 'realtime', 'minute']
|
||||
: (dataSources?.custom?.find(s => s.name === activeProvider)?.datasets || [])
|
||||
const isCustomActive = activeProvider !== 'tickflow'
|
||||
|
||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
|
||||
@@ -425,125 +439,194 @@ export function Layout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
||||
<div
|
||||
className="h-screen grid bg-base text-foreground overflow-hidden transition-[grid-template-columns] duration-200 ease-smooth"
|
||||
style={{ gridTemplateColumns: navCollapsed ? '3.5rem 1fr' : '14rem 1fr' }}
|
||||
>
|
||||
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<div className="px-5 py-5 border-b border-border shrink-0">
|
||||
{/* Brand block — 原创 logo + 等宽 wordmark */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={cn('border-b border-border shrink-0', navCollapsed ? 'px-2 pt-3 pb-2' : 'px-4 pt-4 pb-3')}>
|
||||
{/* Brand block — 收起时只显 logo 居中 */}
|
||||
<div className={cn('flex', navCollapsed ? 'flex-col items-center gap-2' : 'items-center gap-2')}>
|
||||
<Logo
|
||||
size={28}
|
||||
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
|
||||
size={navCollapsed ? 24 : 26}
|
||||
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.4)]"
|
||||
style={{ color: BRAND }}
|
||||
/>
|
||||
<div
|
||||
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
|
||||
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
||||
{!navCollapsed && (
|
||||
<div
|
||||
className="font-bold text-[11px] uppercase tracking-[0.14em] text-foreground whitespace-nowrap"
|
||||
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
||||
>
|
||||
Tick Stock Panel
|
||||
</div>
|
||||
)}
|
||||
{/* 收起/展开 按钮 */}
|
||||
<button
|
||||
onClick={toggleNavCollapsed}
|
||||
className={cn(
|
||||
'flex items-center rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth',
|
||||
navCollapsed ? 'justify-center p-1.5' : 'ml-auto p-1.5',
|
||||
)}
|
||||
title={navCollapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
<div>TickFlow</div>
|
||||
<div>Stock Panel</div>
|
||||
{navCollapsed
|
||||
? <PanelLeftOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
: <PanelLeftClose className="h-3.5 w-3.5 shrink-0" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 状态卡 — 收起时隐藏 */}
|
||||
{!navCollapsed && (
|
||||
<div className="mt-2.5 space-y-0.5">
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
providerName={activeProviderName}
|
||||
isTickflow={!isCustomActive}
|
||||
/>
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
||||
model={settingsState?.ai_model}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
Quant · Terminal
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 h-px"
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
/>
|
||||
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
/>
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
||||
model={settingsState?.ai_model}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
{visibleNavItems.map(({ to, label, icon: Icon, badge }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<span className="ml-auto inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
||||
{to === '/data' && isDataSyncing && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
)}
|
||||
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
|
||||
)}
|
||||
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
|
||||
{to === '/monitor' && <MonitorBadge active={isActive} />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleNavItems.map(({ to, label, icon: Icon, badge }) => {
|
||||
// 「自选」项 — 开启分组侧栏且未整体收起时, 渲染为可展开父项 + 二级分组
|
||||
const isWatchlistExpandable = to === '/watchlist' && groupsInNav && !navCollapsed && watchlistGroups.length > 0
|
||||
return (
|
||||
<div key={to}>
|
||||
{isWatchlistExpandable ? (
|
||||
/* 可展开的自选父项 — 点击切换展开, 不直接跳页 */
|
||||
<button
|
||||
onClick={() => setWatchlistNavExpanded(v => !v)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-3 rounded-btn px-3 py-2 text-sm transition-all duration-150 ease-smooth',
|
||||
location.pathname === '/watchlist'
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/75 hover:bg-elevated/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-1/2 h-4 -translate-y-1/2 w-[2.5px] rounded-full bg-accent transition-opacity duration-150',
|
||||
location.pathname === '/watchlist' ? 'opacity-100 shadow-[0_0_8px_rgba(59,130,246,0.6)]' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', location.pathname === '/watchlist' ? 'text-accent' : 'text-foreground/60 group-hover:text-foreground/85')} />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
{watchlistNavExpanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
/* 普通菜单项 */
|
||||
<NavLink
|
||||
to={to}
|
||||
title={navCollapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center rounded-btn text-sm transition-all duration-150 ease-smooth',
|
||||
navCollapsed ? 'justify-center px-0 py-2' : 'gap-3 px-3 py-2',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/75 hover:bg-elevated/70 hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{/* active 左侧 accent 竖条指示 */}
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-1/2 h-4 -translate-y-1/2 w-[2.5px] rounded-full bg-accent transition-opacity duration-150',
|
||||
isActive ? 'opacity-100 shadow-[0_0_8px_rgba(59,130,246,0.6)]' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', isActive ? 'text-accent' : 'text-foreground/60 group-hover:text-foreground/85')} />
|
||||
{!navCollapsed && <span className="flex-1">{label}</span>}
|
||||
{!navCollapsed && badge && (
|
||||
<span className="ml-auto inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
||||
{to === '/data' && isDataSyncing && !navCollapsed && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
)}
|
||||
{to === '/data' && !isDataSyncing && dataSyncJustDone && !navCollapsed && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
|
||||
)}
|
||||
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
|
||||
{to === '/monitor' && !navCollapsed && <MonitorBadge active={isActive} />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* 自选分组二级子菜单 — 展开时显示 */}
|
||||
{isWatchlistExpandable && watchlistNavExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
<NavLink
|
||||
to="/watchlist"
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-2 rounded-btn py-1.5 pl-9 pr-3 text-[12px] transition-colors duration-150 ease-smooth',
|
||||
isActive && !location.search
|
||||
? 'text-accent font-medium'
|
||||
: 'text-foreground/60 hover:text-foreground hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
|
||||
<span>全部</span>
|
||||
</NavLink>
|
||||
{watchlistGroups.map(group => {
|
||||
const color = resolveWatchlistGroupColor(group.color)
|
||||
const groupPath = `/watchlist?group=${group.id}`
|
||||
const isGroupActive = location.pathname === '/watchlist' && location.search === `?group=${group.id}`
|
||||
return (
|
||||
<NavLink
|
||||
key={group.id}
|
||||
to={groupPath}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-btn py-1.5 pl-9 pr-3 text-[12px] transition-colors duration-150 ease-smooth',
|
||||
isGroupActive
|
||||
? 'text-accent font-medium'
|
||||
: 'text-foreground/60 hover:text-foreground hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />
|
||||
<span className="truncate">{group.name}</span>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 数据源状态条 */}
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=data-sources')}
|
||||
className="mx-2 mb-1 flex items-center gap-2 rounded-btn px-2.5 py-2 text-left transition-colors hover:bg-elevated/60 shrink-0 group"
|
||||
title="数据源设置"
|
||||
>
|
||||
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${
|
||||
isCustomActive ? 'bg-accent/15' : 'bg-elevated'
|
||||
}`}>
|
||||
<Database className={`h-3 w-3 ${isCustomActive ? 'text-accent' : 'text-muted'}`} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-secondary truncate group-hover:text-foreground transition-colors">
|
||||
{activeProviderName}
|
||||
</span>
|
||||
{isCustomActive && (
|
||||
<span className="shrink-0 rounded bg-accent/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-accent">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-0.5">
|
||||
{(['daily', 'adj_factor', 'realtime', 'minute'] as const).map(ds => {
|
||||
const supported = ds === 'daily' || ds === 'adj_factor' || ds === 'realtime' || ds === 'minute'
|
||||
const active = supported && (
|
||||
isCustomActive ? activeProviderDatasets.includes(ds) : true
|
||||
)
|
||||
return (
|
||||
<span
|
||||
key={ds}
|
||||
title={ds}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||
active ? 'bg-accent/60' : 'bg-muted/20'
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 全局行情开关 — 收起时只显示状态指示点 */}
|
||||
{navCollapsed ? (
|
||||
<div className="border-t border-border px-2 py-2.5 shrink-0 flex justify-center">
|
||||
<button
|
||||
onClick={() => handleToggle(!realtimeEnabled)}
|
||||
disabled={toggleQuote.isPending || isPaused}
|
||||
title={realtimeEnabled ? (isRunning && isTrading ? '行情运行中 · 点击关闭' : '实时行情已开启') : '实时行情已关闭 · 点击开启'}
|
||||
className="flex items-center justify-center rounded-btn p-1.5 transition-colors hover:bg-elevated/70"
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${
|
||||
realtimeEnabled && isRunning && isTrading
|
||||
? 'bg-accent animate-pulse'
|
||||
: realtimeEnabled ? 'bg-warning/60' : 'bg-muted'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 全局行情开关 */}
|
||||
) : (
|
||||
<div className="border-t border-border px-3 py-2.5 shrink-0">
|
||||
{isNoneTier && !realtimeProviderName ? (
|
||||
<div>
|
||||
@@ -634,28 +717,41 @@ export function Layout() {
|
||||
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border px-2 py-3 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('border-t border-border py-3 shrink-0', navCollapsed ? 'px-2 flex flex-col items-center gap-1' : 'px-2')}>
|
||||
<div className={navCollapsed ? 'flex flex-col items-center gap-1' : 'flex items-center gap-1'}>
|
||||
<ThemeToggle />
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={navCollapsed ? '设置' : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
'group relative flex items-center rounded-btn text-sm transition-all duration-150 ease-smooth',
|
||||
navCollapsed ? 'justify-center px-0 py-2' : 'flex-1 gap-3 px-3 py-2',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
||||
: 'text-foreground/75 hover:bg-elevated/70 hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
<span>设置</span>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted/50 select-none">
|
||||
{version ?? ''}
|
||||
</span>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-1/2 h-4 -translate-y-1/2 w-[2.5px] rounded-full bg-accent transition-opacity duration-150',
|
||||
isActive ? 'opacity-100 shadow-[0_0_8px_rgba(59,130,246,0.6)]' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Settings className={cn('h-4 w-4 shrink-0 transition-colors', isActive ? 'text-accent' : 'text-foreground/60 group-hover:text-foreground/85')} />
|
||||
{!navCollapsed && <span>设置</span>}
|
||||
{!navCollapsed && version && (
|
||||
<span className="ml-auto font-mono text-[10px] text-muted/50 select-none shrink-0">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
|
||||
import { StockInfoBar } from '@/components/StockInfoBar'
|
||||
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
|
||||
@@ -61,6 +62,7 @@ export function StockPanel({
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [intradayDismissed, setIntradayDismissed] = useState(false)
|
||||
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
|
||||
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
|
||||
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
|
||||
@@ -86,6 +88,7 @@ export function StockPanel({
|
||||
|
||||
const handleDateClick = useCallback((date: string) => {
|
||||
setSelectedDate(date)
|
||||
setIntradayDismissed(false)
|
||||
onSelectDate?.(date)
|
||||
}, [onSelectDate])
|
||||
|
||||
@@ -159,16 +162,25 @@ export function StockPanel({
|
||||
extColumns={extColumns}
|
||||
/>
|
||||
|
||||
{showIntraday && selectedDate && (
|
||||
<StockIntradayChart
|
||||
symbol={symbol}
|
||||
date={selectedDate}
|
||||
height={height}
|
||||
prevClose={prevClose}
|
||||
onPriceHover={setLinkedPrice}
|
||||
className="flex-1 min-w-0 border-l border-border pl-3"
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
/>
|
||||
{showIntraday && selectedDate && !intradayDismissed && (
|
||||
<div className="relative flex-1 min-w-0 border-l border-border pl-3">
|
||||
<button
|
||||
onClick={() => setIntradayDismissed(true)}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-surface text-muted shadow-sm transition-colors hover:text-foreground hover:bg-elevated"
|
||||
title="收起分时图"
|
||||
aria-label="收起分时图"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
<StockIntradayChart
|
||||
symbol={symbol}
|
||||
date={selectedDate}
|
||||
height={height}
|
||||
prevClose={prevClose}
|
||||
onPriceHover={setLinkedPrice}
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, RefreshCw, Clock, LineChart } from 'lucide-react'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
@@ -58,6 +60,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
|
||||
const [dateRange, setDateRange] = useState(getDefaultRange)
|
||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
@@ -152,7 +155,10 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col"
|
||||
className={cn(
|
||||
'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth',
|
||||
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]',
|
||||
)}
|
||||
>
|
||||
{/* 顶栏 */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-5 shrink-0">
|
||||
@@ -169,88 +175,49 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
{name && <span className="truncate text-xs text-muted">{name}</span>}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* 区间选择 — 随视图切换 */}
|
||||
{view === 'daily' ? (
|
||||
<>
|
||||
{/* 日期范围快捷 */}
|
||||
{PRESETS.map(p => {
|
||||
const now = new Date()
|
||||
const s = new Date(now)
|
||||
s.setMonth(s.getMonth() - p.months)
|
||||
const expected = s.toISOString().slice(0, 10)
|
||||
const isActive = dateRange.start === expected
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => {
|
||||
const end = new Date().toISOString().slice(0, 10)
|
||||
const ns = new Date()
|
||||
ns.setMonth(ns.getMonth() - p.months)
|
||||
setDateRange({ start: ns.toISOString().slice(0, 10), end })
|
||||
}}
|
||||
className={`h-6 px-1.5 rounded text-[11px] transition-colors cursor-pointer
|
||||
${isActive
|
||||
? 'bg-accent/20 text-accent font-medium border border-accent/30'
|
||||
: 'text-muted hover:text-foreground hover:bg-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<DatePicker
|
||||
value={dateRange.start}
|
||||
onChange={(v) => setDateRange(prev => ({ ...prev, start: v }))}
|
||||
max={dateRange.end}
|
||||
/>
|
||||
<span className="text-muted/40 text-[10px]">~</span>
|
||||
<DatePicker
|
||||
value={dateRange.end}
|
||||
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
|
||||
min={dateRange.start}
|
||||
/>
|
||||
</>
|
||||
<div className="flex items-center gap-1">
|
||||
{PRESETS.map(p => {
|
||||
const now = new Date()
|
||||
const s = new Date(now)
|
||||
s.setMonth(s.getMonth() - p.months)
|
||||
const expected = s.toISOString().slice(0, 10)
|
||||
const isActive = dateRange.start === expected
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => {
|
||||
const end = new Date().toISOString().slice(0, 10)
|
||||
const ns = new Date()
|
||||
ns.setMonth(ns.getMonth() - p.months)
|
||||
setDateRange({ start: ns.toISOString().slice(0, 10), end })
|
||||
}}
|
||||
className={`h-6 px-1.5 rounded text-[11px] transition-colors cursor-pointer
|
||||
${isActive
|
||||
? 'bg-accent/20 text-accent font-medium border border-accent/30'
|
||||
: 'text-muted hover:text-foreground hover:bg-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<DatePicker
|
||||
value={dateRange.start}
|
||||
onChange={(v) => setDateRange(prev => ({ ...prev, start: v }))}
|
||||
max={dateRange.end}
|
||||
/>
|
||||
<span className="text-muted/40 text-[10px]">~</span>
|
||||
<DatePicker
|
||||
value={dateRange.end}
|
||||
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
|
||||
min={dateRange.start}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="shrink-0 text-[10px] text-muted">区间</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<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
|
||||
@@ -268,18 +235,97 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="mx-0.5 h-4 w-px shrink-0 bg-border" />
|
||||
|
||||
{/* 日K / 分时 切换 */}
|
||||
<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>
|
||||
|
||||
<span className="mx-0.5 h-4 w-px shrink-0 bg-border" />
|
||||
|
||||
{/* 自选 */}
|
||||
{inWatchlist ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleWatchlist.mutate({ action: 'remove' })}
|
||||
disabled={toggleWatchlist.isPending}
|
||||
className="rounded-btn p-1.5 text-[#FACC15] transition-colors cursor-pointer hover:bg-elevated disabled:opacity-50"
|
||||
title="移出自选"
|
||||
aria-label={`将 ${symbol} 移出自选`}
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<WatchlistAddMenu
|
||||
onSelect={groupId => toggleWatchlist.mutate({ action: 'add', groupId })}
|
||||
disabled={toggleWatchlist.isPending}
|
||||
triggerClassName="rounded-btn p-1.5 text-muted transition-colors cursor-pointer hover:bg-elevated hover:text-foreground disabled:opacity-50"
|
||||
ariaLabel={`将 ${symbol} 加入自选`}
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</WatchlistAddMenu>
|
||||
)}
|
||||
{/* 加监控 */}
|
||||
<button
|
||||
onClick={() => setShowMonitorEditor(true)}
|
||||
className="p-1.5 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
|
||||
title="加监控"
|
||||
>
|
||||
<RadioTower className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 刷新 */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
||||
className="p-1.5 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 放大 / 缩小 */}
|
||||
<button
|
||||
onClick={() => setMaximized(v => !v)}
|
||||
className="p-1.5 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title={maximized ? '缩小' : '放大'}
|
||||
>
|
||||
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-btn p-1.5 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
||||
aria-label="关闭个股详情"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,13 +377,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
<StockPanel
|
||||
symbol={symbol}
|
||||
height={420}
|
||||
showIntraday={false}
|
||||
showIntraday
|
||||
dateRange={dateRange}
|
||||
onMonitor={() => setShowMonitorEditor(true)}
|
||||
inWatchlist={inWatchlist}
|
||||
onAddToWatchlist={groupId => toggleWatchlist.mutate({ action: 'add', groupId })}
|
||||
onRemoveFromWatchlist={() => toggleWatchlist.mutate({ action: 'remove' })}
|
||||
watchlistPending={toggleWatchlist.isPending}
|
||||
/>
|
||||
) : (
|
||||
<StockMultiDayIntradayChart
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Check, FolderCog, FolderInput, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, FolderCog, FolderInput, Pencil, Plus, Trash2, X, Eraser } from 'lucide-react'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import type { WatchlistGroup, WatchlistGroupColor } from '@/lib/api'
|
||||
import {
|
||||
DEFAULT_WATCHLIST_GROUP_COLOR,
|
||||
@@ -19,6 +23,7 @@ interface GroupBarProps {
|
||||
onCreate: (name: string, color: WatchlistGroupColor) => Promise<void>
|
||||
onRename: (groupId: string, name: string, color: WatchlistGroupColor) => Promise<void>
|
||||
onDelete: (groupId: string) => Promise<void>
|
||||
onClearGroup?: (groupId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function WatchlistGroupBar({
|
||||
@@ -30,8 +35,10 @@ export function WatchlistGroupBar({
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClearGroup,
|
||||
}: GroupBarProps) {
|
||||
const [managerOpen, setManagerOpen] = useState(false)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const tabs = [
|
||||
{ id: 'all', name: '全部', count: total, color: null },
|
||||
{ id: 'ungrouped', name: '未分组', count: counts.ungrouped ?? 0, color: null },
|
||||
@@ -80,8 +87,50 @@ export function WatchlistGroupBar({
|
||||
>
|
||||
<FolderCog className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 清空当前分组 — 仅选中具体分组时显示 */}
|
||||
{onClearGroup && selected !== 'all' && selected !== 'ungrouped' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="inline-flex w-8 shrink-0 items-center justify-center text-muted hover:text-warning"
|
||||
title="清空当前分组"
|
||||
aria-label="清空当前分组"
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 清空分组确认弹窗 */}
|
||||
{confirmClear && selected !== 'all' && selected !== 'ungrouped' && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
/>
|
||||
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">清空分组</h3>
|
||||
<p className="text-xs text-secondary mb-5">
|
||||
确认清空「{tabs.find(t => t.id === selected)?.name}」分组? 分组内所有股票将转为未分组(不从自选中删除)。
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirmClear(false); void onClearGroup?.(selected) }}
|
||||
className="px-3 py-1.5 rounded-btn bg-warning/15 text-warning hover:bg-warning/25 text-sm font-medium transition-colors"
|
||||
>
|
||||
确认清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{managerOpen && (
|
||||
<GroupManagerDialog
|
||||
groups={groups}
|
||||
@@ -137,7 +186,7 @@ function GroupManagerDialog({
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: Omit<GroupBarProps, 'selected' | 'total' | 'onSelect'> & { onClose: () => void }) {
|
||||
}: Omit<GroupBarProps, 'selected' | 'total' | 'onSelect' | 'onClearGroup'> & { onClose: () => void }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState<WatchlistGroupColor>(DEFAULT_WATCHLIST_GROUP_COLOR)
|
||||
@@ -148,6 +197,21 @@ function GroupManagerDialog({
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 「显示在侧边栏」偏好开关
|
||||
const qc = useQueryClient()
|
||||
const prefs = usePreferences()
|
||||
const groupsInNav = prefs.data?.watchlist_groups_in_nav ?? false
|
||||
const [navTogglePending, setNavTogglePending] = useState(false)
|
||||
const toggleGroupsInNav = async (enabled: boolean) => {
|
||||
setNavTogglePending(true)
|
||||
try {
|
||||
await api.updateWatchlistGroupsInNav(enabled)
|
||||
await qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
} finally {
|
||||
setNavTogglePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (name: string) => {
|
||||
const value = name.trim()
|
||||
if (!value) return '请输入分组名称'
|
||||
@@ -210,6 +274,27 @@ function GroupManagerDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 显示在侧边栏 开关 */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-foreground">显示在侧边栏</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">开启后可在左侧菜单展开分组子菜单</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleGroupsInNav(!groupsInNav)}
|
||||
disabled={navTogglePending}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${
|
||||
groupsInNav ? 'bg-accent' : 'bg-elevated'
|
||||
}`}
|
||||
title={groupsInNav ? '已开启 — 点击关闭' : '已关闭 — 点击开启'}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
groupsInNav ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@@ -19,6 +19,8 @@ export function ExtDataPullPanel({ config, onSaved }: {
|
||||
pull?.field_map ? JSON.stringify(pull.field_map, null, 2) : ''
|
||||
)
|
||||
const [schedule, setSchedule] = useState(pull?.schedule_minutes ?? 1440)
|
||||
const [timeWindowStart, setTimeWindowStart] = useState(pull?.time_window_start ?? '')
|
||||
const [timeWindowEnd, setTimeWindowEnd] = useState(pull?.time_window_end ?? '')
|
||||
const [enabled, setEnabled] = useState(pull?.enabled ?? false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -44,6 +46,8 @@ export function ExtDataPullPanel({ config, onSaved }: {
|
||||
url, method, headers, body: body || undefined,
|
||||
response_path: responsePath, field_map,
|
||||
schedule_minutes: schedule, enabled: enabledOverride ?? enabled,
|
||||
time_window_start: timeWindowStart || null,
|
||||
time_window_end: timeWindowEnd || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +186,23 @@ export function ExtDataPullPanel({ config, onSaved }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">拉取起始时间 (留空=不限)</div>
|
||||
<input
|
||||
type="time" value={timeWindowStart} onChange={e => setTimeWindowStart(e.target.value)}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">拉取结束时间 (留空=不限)</div>
|
||||
<input
|
||||
type="time" value={timeWindowEnd} onChange={e => setTimeWindowEnd(e.target.value)}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">字段映射 (外部名 → 内部名,JSON,可选)</div>
|
||||
<textarea
|
||||
|
||||
@@ -1023,6 +1023,7 @@ export interface WecomBotStatus {
|
||||
export interface Preferences {
|
||||
realtime_quotes_enabled: boolean
|
||||
indices_nav_pinned: boolean
|
||||
watchlist_groups_in_nav: boolean
|
||||
minute_sync_enabled: boolean
|
||||
minute_sync_days: number
|
||||
minute_sync_segment_days: number
|
||||
@@ -1236,6 +1237,11 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ indices_nav_pinned: pinned }),
|
||||
}),
|
||||
updateWatchlistGroupsInNav: (enabled: boolean) =>
|
||||
request<{ watchlist_groups_in_nav: boolean }>('/api/settings/preferences/watchlist-groups-in-nav', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ watchlist_groups_in_nav: enabled }),
|
||||
}),
|
||||
quoteStatus: () =>
|
||||
request<{
|
||||
enabled: boolean
|
||||
@@ -1573,6 +1579,11 @@ export const api = {
|
||||
`/api/watchlist/groups/${encodeURIComponent(groupId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
watchlistGroupClear: (groupId: string) =>
|
||||
request<{ symbols: WatchlistEntry[] }>(
|
||||
`/api/watchlist/groups/${encodeURIComponent(groupId)}/clear`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
watchlistSetGroup: (symbol: string, groupId: string | null) =>
|
||||
request<{ symbols: WatchlistEntry[] }>(
|
||||
`/api/watchlist/${encodeURIComponent(symbol)}/group`,
|
||||
@@ -1869,6 +1880,7 @@ export const api = {
|
||||
url: string; method?: string; headers?: Record<string, string>; body?: string;
|
||||
response_path?: string; field_map?: Record<string, string>;
|
||||
schedule_minutes?: number; enabled?: boolean;
|
||||
time_window_start?: string | null; time_window_end?: string | null;
|
||||
}) =>
|
||||
request<{ status: string; pull: PullConfig }>(
|
||||
`/api/ext-data/${id}/pull`,
|
||||
@@ -2540,6 +2552,8 @@ export interface PullConfig {
|
||||
last_message?: string | null
|
||||
last_rows?: number | null
|
||||
next_run?: string | null
|
||||
time_window_start?: string | null
|
||||
time_window_end?: string | null
|
||||
}
|
||||
|
||||
export interface ExtDataDetectUrlRequest {
|
||||
|
||||
@@ -660,7 +660,7 @@ export function Data() {
|
||||
<span className="text-secondary leading-relaxed">
|
||||
当前为 None 档,将使用免费数据源获取历史日K(无需注册)。
|
||||
配置 API Key 可解锁实时行情监控等扩展能力,前往
|
||||
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-accent hover:underline">
|
||||
<Link to="/settings?tab=data-sources" className="mx-0.5 font-medium text-accent hover:underline">
|
||||
配置
|
||||
</Link>
|
||||
。
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* 通过 URL query param ?tab=xxx 同步 Tab 状态。
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BarChart3, Database, Key, Radio, SlidersHorizontal, Sparkles, Settings2, Zap } from 'lucide-react'
|
||||
import { SettingsKeysPanel } from './settings/Keys'
|
||||
import { BarChart3, Database, Radio, SlidersHorizontal, Sparkles, Settings2, Zap, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { SettingsAIPanel } from './settings/AI'
|
||||
import { SettingsMonitoringPanel } from './settings/Monitoring'
|
||||
import { SettingsExtPagesPanel } from './settings/ExtPages'
|
||||
@@ -30,10 +30,9 @@ type TabDef = {
|
||||
}
|
||||
|
||||
const TABS: readonly TabDef[] = [
|
||||
{ key: 'account', label: 'TickFlow', icon: Key, panel: SettingsKeysPanel },
|
||||
{ key: 'data-sources', label: '数据源', icon: Database, panel: SettingsDataSourcesPanel },
|
||||
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
|
||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||
{ key: 'data-sources', label: '数据源', icon: Database, panel: SettingsDataSourcesPanel, badge: 'beta' },
|
||||
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
||||
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
|
||||
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
||||
@@ -48,6 +47,18 @@ export function Settings() {
|
||||
const activeTab = TABS.find((t) => t.key === tabParam) ?? TABS[0]
|
||||
const highlight = searchParams.get('highlight') ?? ''
|
||||
|
||||
// 设置菜单收起状态 — 持久化到 localStorage
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem('tf-settings-nav-collapsed') === '1' } catch { return false }
|
||||
})
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(prev => {
|
||||
const next = !prev
|
||||
try { localStorage.setItem('tf-settings-nav-collapsed', next ? '1' : '0') } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -57,23 +68,42 @@ export function Settings() {
|
||||
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex gap-6 items-stretch">
|
||||
{/* ===== 竖向 Tab 侧栏(内容垂直居中) ===== */}
|
||||
<nav className="w-36 shrink-0">
|
||||
<div className="flex flex-col gap-0.5 justify-center min-h-[60vh] sticky top-6">
|
||||
{/* ===== 竖向 Tab 侧栏 ===== */}
|
||||
<nav className={cn('shrink-0 transition-all duration-200 ease-smooth', collapsed ? 'w-10' : 'w-36')}>
|
||||
<div className="flex flex-col gap-0.5 min-h-[60vh] sticky top-6">
|
||||
{/* 收起/展开 按钮 */}
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth mb-1',
|
||||
collapsed ? 'justify-center px-0 py-2' : 'px-3 py-2 text-xs',
|
||||
)}
|
||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{collapsed
|
||||
? <PanelLeftOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
: <PanelLeftClose className="h-3.5 w-3.5 shrink-0" />
|
||||
}
|
||||
{!collapsed && <span>收起菜单</span>}
|
||||
</button>
|
||||
|
||||
{/* Tab 按钮列表 — 收起时只显示图标 */}
|
||||
{TABS.map(({ key, label, icon: Icon, badge }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSearchParams({ tab: key }, { replace: true })}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth text-left',
|
||||
'relative flex items-center rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
collapsed ? 'justify-center px-0 py-2' : 'items-center gap-2 px-3 py-2 text-left',
|
||||
activeTab.key === key
|
||||
? 'bg-accent/10 text-accent font-medium'
|
||||
: 'text-secondary hover:text-foreground hover:bg-elevated/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{label}</span>
|
||||
{badge && (
|
||||
{!collapsed && <span>{label}</span>}
|
||||
{!collapsed && badge && (
|
||||
<span className="ml-auto inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen } from 'lucide-react'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus } 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'
|
||||
@@ -617,7 +618,14 @@ 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 [searchParams] = useSearchParams()
|
||||
const initialGroup = (searchParams.get('group') as WatchlistGroupFilter | null) ?? 'all'
|
||||
const [selectedGroup, setSelectedGroup] = useState<WatchlistGroupFilter>(initialGroup)
|
||||
// URL ?group= 变化时同步选中分组 (侧边栏二级菜单切换分组时触发)
|
||||
useEffect(() => {
|
||||
const g = (searchParams.get('group') as WatchlistGroupFilter | null) ?? 'all'
|
||||
setSelectedGroup(g)
|
||||
}, [searchParams])
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
|
||||
const [ocrInstallHint, setOcrInstallHint] = useState('')
|
||||
const columnsLoaded = useRef(false)
|
||||
@@ -870,6 +878,11 @@ export function Watchlist() {
|
||||
},
|
||||
})
|
||||
|
||||
const clearGroup = useMutation({
|
||||
mutationFn: (groupId: string) => api.watchlistGroupClear(groupId),
|
||||
onSuccess: data => qc.setQueryData(QK.watchlist, data),
|
||||
})
|
||||
|
||||
const assignGroup = useMutation({
|
||||
mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) =>
|
||||
api.watchlistSetGroup(symbol, groupId),
|
||||
@@ -1229,6 +1242,7 @@ export function Watchlist() {
|
||||
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)}
|
||||
onClearGroup={groupId => clearGroup.mutateAsync(groupId).then(() => undefined)}
|
||||
/>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
@@ -1431,7 +1445,7 @@ export function Watchlist() {
|
||||
) : null}
|
||||
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
|
||||
</button>
|
||||
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
|
||||
{/* 删除入口:从分组移除 + 从自选移除(二次确认) + 移到顶部 */}
|
||||
<div className="ml-auto pl-1 shrink-0">
|
||||
{confirmRemove === r.symbol ? (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -1457,11 +1471,22 @@ export function Watchlist() {
|
||||
disabled={assignGroup.isPending}
|
||||
onChange={handleGroupChange}
|
||||
/>
|
||||
{r.group_id && (
|
||||
<button
|
||||
onClick={() => handleGroupChange(r.symbol, null)}
|
||||
disabled={assignGroup.isPending}
|
||||
className="p-0.5 text-muted hover:text-warning transition-colors duration-150 ease-smooth disabled:opacity-50"
|
||||
aria-label="从分组移除"
|
||||
title="从分组移除"
|
||||
>
|
||||
<FolderMinus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmRemove(r.symbol)}
|
||||
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
|
||||
aria-label="移除"
|
||||
title="移除"
|
||||
title="从自选移除"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { DataSourceEditor } from './DataSourceEditor'
|
||||
import { TickFlowKeyConfig } from './Keys'
|
||||
|
||||
const DATASET_LABEL: Record<string, string> = {
|
||||
daily: '日K',
|
||||
@@ -431,51 +432,56 @@ function PluginDetail({ plugin, isActive, onSwitch, switching }: {
|
||||
|
||||
function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSwitch: () => void; switching: boolean }) {
|
||||
return (
|
||||
<section className="rounded-card border border-border bg-surface p-6">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<Database className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-semibold text-foreground">TickFlow</h2>
|
||||
<span className="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">内置默认</span>
|
||||
{active && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
<Check className="h-2.5 w-2.5" /> 当前使用
|
||||
</span>
|
||||
)}
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-card border border-border bg-surface p-6">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<Database className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
|
||||
项目默认数据源。日K、除权因子、实时行情、分钟K均由 TickFlow 提供,无需额外配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mb-5">
|
||||
{[
|
||||
{ label: '日K', desc: '历史 + 实时覆写' },
|
||||
{ label: '除权因子', desc: 'Starter+ 能力' },
|
||||
{ label: '实时行情', desc: '全市场快照' },
|
||||
{ label: '分钟K', desc: 'Pro+ 能力' },
|
||||
].map(f => (
|
||||
<div key={f.label} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5">
|
||||
<div className="text-xs font-medium text-foreground">{f.label}</div>
|
||||
<div className="text-[10px] text-muted mt-0.5">{f.desc}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-semibold text-foreground">TickFlow</h2>
|
||||
<span className="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">内置默认</span>
|
||||
{active && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
<Check className="h-2.5 w-2.5" /> 当前使用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
|
||||
项目默认数据源。日K、除权因子、实时行情、分钟K均由 TickFlow 提供,无需额外配置。
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!active && (
|
||||
<button
|
||||
onClick={onSwitch}
|
||||
disabled={switching}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
切换为当前数据源
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mb-5">
|
||||
{[
|
||||
{ label: '日K', desc: '历史 + 实时覆写' },
|
||||
{ label: '除权因子', desc: 'Starter+ 能力' },
|
||||
{ label: '实时行情', desc: '全市场快照' },
|
||||
{ label: '分钟K', desc: 'Pro+ 能力' },
|
||||
].map(f => (
|
||||
<div key={f.label} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5">
|
||||
<div className="text-xs font-medium text-foreground">{f.label}</div>
|
||||
<div className="text-[10px] text-muted mt-0.5">{f.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!active && (
|
||||
<button
|
||||
onClick={onSwitch}
|
||||
disabled={switching}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
切换为当前数据源
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* TickFlow API Key 配置 + 订阅档位 + 可用功能 (原 account tab 内容) */}
|
||||
<TickFlowKeyConfig />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { CAP_LABELS, tierTextStyle, tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
|
||||
|
||||
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
|
||||
// ===== TickFlow Key 配置主体 (可嵌入 DataSources 的 TickFlow 详情区) =====
|
||||
|
||||
export function SettingsKeysPanel() {
|
||||
export function TickFlowKeyConfig() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -293,7 +293,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
实时行情需要 Free 及以上档位。None 档可使用 free-api 获取历史日K(当日数据需盘后1-2小时),但不能调用付费服务器实时接口。
|
||||
</p>
|
||||
<a
|
||||
href="/settings?tab=account"
|
||||
href="/settings?tab=data-sources"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-btn
|
||||
bg-accent text-white text-sm font-medium
|
||||
hover:bg-accent/90 transition-colors"
|
||||
|
||||
@@ -89,7 +89,7 @@ export const router = createBrowserRouter([
|
||||
// 隐藏路由:开发者工具(不暴露在菜单,仅供调试)
|
||||
{ path: 'dev', element: <Dev /> },
|
||||
// 旧路由兼容重定向
|
||||
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
|
||||
{ path: 'settings/keys', element: <Navigate to="/settings?tab=data-sources" replace /> },
|
||||
{ path: 'settings/ai', element: <Navigate to="/settings?tab=ai" replace /> },
|
||||
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user