feat(settings): 补全设置卡片定位闪烁锚点体系

此前「从业务页引导去设置」只有 depth-fix 一处孤例 (SealedBadge → 连板
梯队修正卡片闪烁), 其余 8 个引导链接只带 tab 参数跳转, 用户落地后要
自己在设置页里找目标卡片。

- 通用锚点机制: useCardFlash hook (/settings?tab=X&highlight=<anchor>
  打开时目标卡片 scrollIntoView 居中 + ring 高亮 2s, 一次会话仅触发
  一次防 StrictMode 重放) + AnchorWrap 包裹层 (无统一 Card 组件的面板用)
- Settings 把 highlight 传给全部面板 (面板类型本就声明了 highlight?: string)
- 锚点落位: 监控页 Card 组件 anchor 化 — quotes(行情轮询)/webhooks(推送
  通知)/minute-refresh(盘中分钟增量)/intraday-refresh(分时图刷新)/
  depth-fix(原实现迁移到统一机制); AI ai-connection; 数据源 data-sources;
  信号库 signals
- 8 个引导链接补 &highlight=: RuleEditor/Review webhook 未配置提示 →
  webhooks; Layout 实时无权限/监控入口 → data-sources/quotes; Data 页
  数据源入口 → data-sources; 信号触发器 → signals

验证: pnpm build 通过; 浏览器实测 8 个锚点全部滚动定位+闪烁; 顺带补验
40e54ea 统一策略池欠的界面项 (双池→统一迁移 17/33、分钟徽章、三态筛选
只过滤显示不动池、ETF 视角无分钟策略、分钟卡片实时单跑)。
This commit is contained in:
shy3130
2026-08-30 19:05:10 +08:00
parent a342d94238
commit 8ccb681214
11 changed files with 114 additions and 43 deletions
+2 -2
View File
@@ -754,7 +754,7 @@ export function Layout() {
,
<button
type="button"
onClick={() => navigate('/settings?tab=data-sources')}
onClick={() => navigate('/settings?tab=data-sources&highlight=data-sources')}
className="mx-0.5 text-accent/80 hover:text-accent hover:underline"
>
@@ -777,7 +777,7 @@ export function Layout() {
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => navigate('/settings?tab=monitoring')}
onClick={() => navigate('/settings?tab=monitoring&highlight=quotes')}
aria-label="打开实时监控设置"
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground"
title="实时监控设置"
@@ -1402,7 +1402,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
return (
<p className="text-[10px] leading-relaxed text-warning/80">
{unconfigured.join('、')},
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80"> </Link>
<Link to="/settings?tab=monitoring&highlight=webhooks" className="text-accent hover:text-accent/80"> </Link>
</p>
)
})()}
@@ -38,7 +38,7 @@ export function SignalTriggerActions({ kind, signals, onChange, buttonClassName,
</button>
<button
type="button"
onClick={() => navigate('/settings?tab=signals')}
onClick={() => navigate('/settings?tab=signals&highlight=signals')}
title="去信号库"
className={`${btnCls} hover:border-amber-400/40 hover:text-amber-400`}
>
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
/**
* 设置卡片定位闪烁锚点。
*
* 用法: /settings?tab=monitoring&highlight=<key> 打开时, 声明了 anchor=key 的
* 卡片滚动到视口中央并 ring 高亮 2 秒 — 从其他页面引导用户"去某张卡片开启/配置"
* 时精确定位, 不再让用户在设置页里大海捞针。
*
* 每个锚点 key 一次会话只触发一次 (firedRef), 避免 StrictMode 双渲染重复闪烁。
*/
export function useCardFlash(highlight: string | undefined, key: string) {
const ref = useRef<HTMLDivElement>(null)
const firedRef = useRef(false)
const [flash, setFlash] = useState(false)
useEffect(() => {
if (highlight !== key || firedRef.current || !ref.current) return
firedRef.current = true
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 等滚动起一帧再点亮, 视觉上"滚过去 → 闪一下"更连贯
requestAnimationFrame(() => {
setFlash(true)
window.setTimeout(() => setFlash(false), 2000)
})
}, [highlight, key])
return { ref, flash }
}
/** 闪烁态样式: 与连板梯队修正卡片 (depth-fix) 原实现保持一致 */
export function cardFlashCls(flash: boolean): string {
return `rounded-card transition-all duration-500 ${
flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'
}`
}
/** 锚点包裹层: 面板没有统一 Card 组件时, 直接包住目标区块即可 */
export function AnchorWrap({
highlight,
anchor,
children,
}: {
highlight?: string
anchor: string
children: React.ReactNode
}) {
const { ref, flash } = useCardFlash(highlight, anchor)
return (
<div ref={ref} id={anchor} className={cardFlashCls(flash)}>
{children}
</div>
)
}
+2 -2
View File
@@ -633,7 +633,7 @@ export function Data() {
</button>
<div className="w-px h-4 bg-border" />
<Link
to="/settings?tab=data-sources"
to="/settings?tab=data-sources&highlight=data-sources"
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
title="切换数据源"
>
@@ -661,7 +661,7 @@ export function Data() {
<span className="text-secondary leading-relaxed">
API Key,K将使用免费通道获取
K等能力取决于所选数据源,
<Link to="/settings?tab=data-sources" className="mx-0.5 font-medium text-accent hover:underline">
<Link to="/settings?tab=data-sources&highlight=data-sources" className="mx-0.5 font-medium text-accent hover:underline">
</Link>
+1 -1
View File
@@ -469,7 +469,7 @@ export function Review() {
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
Webhook
{((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && (
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
<Link to="/settings?tab=monitoring&highlight=webhooks" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
</Link>
)}
+1 -1
View File
@@ -125,7 +125,7 @@ export function Settings() {
>
{activeTab.key === 'monitoring'
? <SettingsMonitoringPanel highlight={highlight} />
: <activeTab.panel />}
: <activeTab.panel highlight={highlight} />}
</motion.div>
</div>
</div>
+20 -5
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, createContext, useContext } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
@@ -8,6 +8,7 @@ import {
import { useSettings } from '@/lib/useSharedQueries'
import { api, type SettingsState } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash'
// 统一的输入框样式(与项目其他设置页一致)
const INPUT_CLS =
@@ -70,7 +71,7 @@ const findPreset = (provider: string, baseUrl: string, codexCommand: string) =>
return provider === CODEX_PROVIDER ? p.codexCommand === codexCommand : p.url === baseUrl
}) ?? PRESETS[0]
export function SettingsAIPanel() {
export function SettingsAIPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const settings = useSettings()
const s = settings.data
@@ -306,8 +307,9 @@ export function SettingsAIPanel() {
}
return (
<HighlightContext.Provider value={highlight ?? ''}>
<div className="space-y-5 max-w-2xl">
<Card icon={Plug} title="连接状态" right={
<Card icon={Plug} title="连接状态" anchor="ai-connection" right={
configured && (
<button onClick={handleTest} disabled={testing}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50">
@@ -519,20 +521,27 @@ export function SettingsAIPanel() {
</div>
)}
</div>
</HighlightContext.Provider>
)
}
// ===== 通用卡片(与 Keys 页风格统一) =====
// 卡片定位锚点: highlight=<anchor> 时滚动到视口中央并闪烁 (见 useCardFlash)
const HighlightContext = createContext('')
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
right?: React.ReactNode
children: React.ReactNode
anchor?: string
}
function Card({ icon: Icon, title, right, children }: CardProps) {
return (
function Card({ icon: Icon, title, right, children, anchor }: CardProps) {
const highlight = useContext(HighlightContext)
const { ref, flash } = useCardFlash(anchor ? highlight : undefined, anchor ?? '')
const inner = (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
@@ -544,6 +553,12 @@ function Card({ icon: Icon, title, right, children }: CardProps) {
{children}
</section>
)
if (!anchor) return inner
return (
<div ref={ref} id={anchor} className={cardFlashCls(flash)}>
{inner}
</div>
)
}
// ===== 表单字段(统一 label + 输入框样式) =====
@@ -6,6 +6,7 @@ import { QK } from '@/lib/queryKeys'
import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
import { Skeleton } from '@/components/data/Skeleton'
import { AnchorWrap } from '@/lib/useCardFlash'
type SignalSection = 'builtin' | 'custom'
@@ -16,7 +17,7 @@ const KIND_CLASS: Record<SignalKind, string> = {
both: 'bg-muted/10 text-muted',
}
export function SettingsCustomSignalsPanel() {
export function SettingsCustomSignalsPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
@@ -87,6 +88,7 @@ export function SettingsCustomSignalsPanel() {
return (
<div className="max-w-6xl space-y-6">
<AnchorWrap highlight={highlight} anchor="signals">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(234,179,8,0.12),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
@@ -133,6 +135,7 @@ export function SettingsCustomSignalsPanel() {
</div>
</div>
</section>
</AnchorWrap>
{activeSection === 'builtin' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
+4 -1
View File
@@ -5,6 +5,7 @@ import { Check, Database, Eye, EyeOff, KeyRound, Plus, RefreshCw, Zap, FileWarni
import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { AnchorWrap } from '@/lib/useCardFlash'
import { TIER_RANK, tierRank, tierStyle } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DataSourceEditor } from './DataSourceEditor'
@@ -367,7 +368,7 @@ function PluginKeyConfig({ plugin }: { plugin: PluginDataSourceItem }) {
)
}
export function SettingsDataSourcesPanel() {
export function SettingsDataSourcesPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const prefs = usePreferences()
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources })
@@ -585,6 +586,7 @@ export function SettingsDataSourcesPanel() {
return (
<div className="space-y-5 max-w-5xl">
{/* ===== 顶部: 当前数据源 + 数据源选择 (一个大卡片) ===== */}
<AnchorWrap highlight={highlight} anchor="data-sources">
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
@@ -779,6 +781,7 @@ export function SettingsDataSourcesPanel() {
</div>
</section>
</AnchorWrap>
{/* ===== 下方: 编辑区 ===== */}
<AnimatePresence mode="wait">
+24 -28
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useState, useCallback, useEffect, createContext, useContext } from 'react'
import { Link } from 'react-router-dom'
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
@@ -19,9 +19,14 @@ import {
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
// 卡片定位锚点: highlight=<anchor> 时该卡片滚动到视口中央并闪烁高亮。
// 其他页面用 /settings?tab=monitoring&highlight=<anchor> 精确引导用户到某张卡片。
const HighlightContext = createContext('')
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
'overview-market': '看板',
@@ -288,27 +293,13 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
return () => window.clearTimeout(t)
}, [minuteRefreshIntervalDraft, minuteRefreshInterval, save])
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
return (
<HighlightContext.Provider value={highlight ?? ''}>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
<div className="space-y-6">
{/* 行情状态 — 开关 + 间隔 */}
<Card icon={Activity} title="行情轮询">
<Card icon={Activity} title="行情轮询" anchor="quotes">
<ToggleRow
label="实时行情"
desc={
@@ -408,7 +399,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
)}
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */}
<Card icon={Activity} title="分时图刷新">
<Card icon={Activity} title="分时图刷新" anchor="intraday-refresh">
<ToggleRow
label="自选/策略分时图实时刷新"
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(依赖分钟K批量数据 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
@@ -474,14 +465,11 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
{/* 连板梯队降级修正 (右列顶部) */}
<Card
icon={Flame}
title="连板梯队降级修正"
anchor="depth-fix"
badge={!hasDepth ? '五档盘口不可用' : undefined}
right={hasDepth ? (
<button
@@ -519,10 +507,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<DepthConfigContent disabled />
)}
</Card>
</div>
{/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */}
<Card icon={Zap} title="盘中分钟增量">
<Card icon={Zap} title="盘中分钟增量" anchor="minute-refresh">
<ToggleRow
label="盘中分钟增量落盘"
desc={
@@ -575,7 +562,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
飞书 / 企业微信。
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
<Card icon={Webhook} title="推送通知">
<Card icon={Webhook} title="推送通知" anchor="webhooks">
<p className="text-xs text-secondary mb-3">
,<b className="text-foreground/80"></b>,
@@ -841,6 +828,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</Card>
</div>
</div>
</HighlightContext.Provider>
)
}
@@ -899,8 +887,10 @@ interface CardProps {
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
function Card({ icon: Icon, title, badge, right, children, anchor }: CardProps & { anchor?: string }) {
const highlight = useContext(HighlightContext)
const { ref, flash } = useCardFlash(anchor ? highlight : undefined, anchor ?? '')
const inner = (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
@@ -917,4 +907,10 @@ function Card({ icon: Icon, title, badge, right, children }: CardProps) {
{children}
</section>
)
if (!anchor) return inner
return (
<div ref={ref} id={anchor} className={cardFlashCls(flash)}>
{inner}
</div>
)
}