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:
shy3130
2026-08-10 17:53:14 +08:00
parent 0c5adb0046
commit c90b83c3e4
25 changed files with 2644 additions and 2193 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.88"
__version__ = "0.2.1"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+4
View File
@@ -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,
+13
View File
@@ -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
+10
View File
@@ -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 引擎是否可用(前端可据此提示安装依赖)。"""
+9 -1
View File
@@ -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"),
)
+26
View File
@@ -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)
+5
View File
@@ -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)
+16
View File
@@ -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 -1
View File
@@ -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"
+39
View File
@@ -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
+1841 -1841
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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>
+22 -10
View File
@@ -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>
+132 -91
View File
@@ -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
+87 -2
View File
@@ -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
+14
View File
@@ -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 {
+1 -1
View File
@@ -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>
+40 -10
View File
@@ -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>
+29 -4
View File
@@ -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>
+49 -43
View File
@@ -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">
KK均由 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">
KK均由 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>
)
}
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -293,7 +293,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
Free None 使 free-api K1-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"
+1 -1
View File
@@ -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 /> },
],