mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(ui): 能力徽章与数据源页重构——路由门控、中立文案、详情卡重排
- 侧栏数据源徽章: 六能力方格(按注册序) + 悬浮路由卡(逐格同色对应, fixed 定位逃逸侧栏裁剪, 渲染后按实际高度钳制视口防遮挡) - 各页能力门控切换为矩阵 usable 视角: Data/Financials/Monitor 缺能力时 提示并引导数据源配置; 门控判定不再绑定 TickFlow 套餐 - 设置页数据源区: 能力路由卡(点标签切换, 乐观更新)、TickFlow 详情改为 左 API Key 右能力档位表、检测档位收进头部徽章集群(+/重检测)、 可用功能收进悬停图标、订阅档位面板移除 - 插件详情与 TickFlow 同构: 能力适配表(服务中/已适配/未就绪/—) + Key 区申请话术嵌官网链接(manifest homepage) - 能力层文案中立化: 通用界面不出现档位/订阅词汇, 能力不可用警示按 usable 判定(TickFlow 档位不足与源未就绪同待遇) - API Key 输入框与保存按钮改为一行左右布局
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, Suspense } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, Suspense } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
@@ -10,7 +10,7 @@ import { AiReportBubble } from '@/components/financials/AiReportBubble'
|
||||
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
|
||||
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
|
||||
import {
|
||||
useCapabilities,
|
||||
useCapabilityMatrix,
|
||||
useSettings,
|
||||
usePreferences,
|
||||
useQuoteStatus,
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||
import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats'
|
||||
@@ -171,80 +171,140 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 档位卡片 =====
|
||||
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'
|
||||
// ===== 数据源能力健康卡 =====
|
||||
// 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 —
|
||||
// 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。
|
||||
// 档位/订阅信息归设置页 TickFlow 介绍卡 (档位词仅出现在 TickFlow 专属界面的设计规则)。
|
||||
// 单能力方格: 可用=绿 / 日K缺失=红 / 其他缺失=琥珀 (与悬浮卡中同色, 一眼对应)
|
||||
function capSquareCls(c: { id: string; usable: boolean }) {
|
||||
return c.usable ? 'bg-accent' : c.id === 'daily' ? 'bg-danger' : 'bg-warning/80'
|
||||
}
|
||||
|
||||
const tierConfig: Record<string, {
|
||||
desc: string
|
||||
dotStyle: React.CSSProperties
|
||||
tagBg: React.CSSProperties
|
||||
labelTextStyle: React.CSSProperties
|
||||
}> = {
|
||||
none: {
|
||||
desc: '未配置 Key · 仅历史日K',
|
||||
dotStyle: { background: '#52525b' },
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '基础日K · 自选实时',
|
||||
dotStyle: { background: '#71717a' },
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
labelTextStyle: { color: '#a1a1aa' },
|
||||
},
|
||||
starter: {
|
||||
desc: '批量同步 · 行情池',
|
||||
dotStyle: { background: '#3b82f6' },
|
||||
tagBg: { background: 'rgba(59,130,246,0.2)' },
|
||||
labelTextStyle: { color: '#60a5fa' },
|
||||
},
|
||||
pro: {
|
||||
desc: '分钟K · 实时行情 · 盘口',
|
||||
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 · 财务数据',
|
||||
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' },
|
||||
},
|
||||
function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefined }) {
|
||||
const caps = matrix?.capabilities ?? []
|
||||
const loading = caps.length === 0
|
||||
const usableCount = caps.filter(c => c.usable).length
|
||||
const down = caps.filter(c => !c.usable)
|
||||
// 日K是核心能力 (其他一切派生于它): 挂了用危险色; 一般缺项琥珀; 全可用绿
|
||||
const level = loading
|
||||
? 'loading'
|
||||
: down.length === 0 ? 'ok' : down.some(c => c.id === 'daily') ? 'danger' : 'warn'
|
||||
const countCls = level === 'ok' ? 'text-accent/80'
|
||||
: level === 'danger' ? 'text-danger'
|
||||
: level === 'warn' ? 'text-warning'
|
||||
: 'text-muted'
|
||||
|
||||
// 悬浮卡: 侧栏 aside 是 overflow-hidden, 用 fixed 定位逃逸裁剪 (坐标取自徽标实时位置)。
|
||||
// 徽标靠近屏幕顶部时居中定位会把卡片上半截推出视口 → 渲染后按实际高度钳制进视口。
|
||||
const linkRef = useRef<HTMLAnchorElement>(null)
|
||||
const popRef = useRef<HTMLDivElement>(null)
|
||||
const closeTimer = useRef<number | undefined>(undefined)
|
||||
const [popPos, setPopPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const openPop = () => {
|
||||
window.clearTimeout(closeTimer.current)
|
||||
const rect = linkRef.current?.getBoundingClientRect()
|
||||
if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 })
|
||||
}
|
||||
|
||||
const t = tierConfig[base] || tierConfig.none
|
||||
const displayLabel = isNone ? 'None' : (label || 'None')
|
||||
const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc
|
||||
const closePop = () => {
|
||||
closeTimer.current = window.setTimeout(() => setPopPos(null), 80)
|
||||
}
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
|
||||
useLayoutEffect(() => {
|
||||
if (!popPos || !popRef.current) return
|
||||
const h = popRef.current.offsetHeight
|
||||
const margin = 8
|
||||
const minCenter = margin + h / 2
|
||||
const maxCenter = window.innerHeight - margin - h / 2
|
||||
const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top))
|
||||
if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped })
|
||||
}, [popPos])
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
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}`}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<DatabaseZap 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>
|
||||
<>
|
||||
<NavLink
|
||||
ref={linkRef}
|
||||
to="/settings?tab=data-sources"
|
||||
aria-label={`数据源能力 ${usableCount}/${caps.length || 5} 可用, 点击前往数据源配置`}
|
||||
onMouseEnter={openPop}
|
||||
onMouseLeave={closePop}
|
||||
onFocus={openPop}
|
||||
onBlur={closePop}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setPopPos(null) }}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
<DatabaseZap className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-accent transition-colors" />
|
||||
{/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */}
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{loading
|
||||
? Array.from({ length: 5 }, (_, i) => (
|
||||
<span key={i} className="h-2 w-2 rounded-[2px] bg-muted animate-pulse" />
|
||||
))
|
||||
: caps.map(c => (
|
||||
<span key={c.id} className={`h-2 w-2 rounded-[2px] ${capSquareCls(c)}`} />
|
||||
))}
|
||||
</span>
|
||||
{!loading && (
|
||||
<span className={`ml-auto text-[10px] font-mono font-bold leading-none shrink-0 ${countCls}`}>
|
||||
{usableCount}/{caps.length}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
{popPos && (
|
||||
<div
|
||||
ref={popRef}
|
||||
className="fixed z-50 -translate-y-1/2 pl-3"
|
||||
style={{ left: popPos.left, top: popPos.top }}
|
||||
onMouseEnter={() => window.clearTimeout(closeTimer.current)}
|
||||
onMouseLeave={closePop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-64 rounded-md border border-border bg-surface py-2.5 pl-3 pr-3.5 shadow-2xl shadow-black/40"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<DatabaseZap className="h-3.5 w-3.5 text-accent" />
|
||||
数据源能力
|
||||
</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${countCls}`}>
|
||||
{loading ? '获取中…' : `${usableCount}/${caps.length} 可用`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 border-t border-border/60 pt-2">
|
||||
{loading ? (
|
||||
<div className="py-0.5 text-[11px] text-muted">正在获取能力路由状态…</div>
|
||||
) : caps.map(c => (
|
||||
<div key={c.id} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-[2px] ${capSquareCls(c)}`} />
|
||||
<span className="shrink-0 text-xs font-medium text-secondary">{c.label}</span>
|
||||
<span className="ml-auto flex min-w-0 shrink items-center gap-1.5">
|
||||
{c.usable ? (
|
||||
<>
|
||||
<span className="truncate text-[11px] text-muted">{c.effective_display}</span>
|
||||
<CheckCircle2 className="h-3 w-3 shrink-0 text-accent" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted/70">未接入</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 分时有分钟K功能替身 (intraday_monitor_support 三路可达), 不单独占能力格, 在此备注 */}
|
||||
<div className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
分时信号监控可由分钟 K 数据驱动,不单独设能力格
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 border-t border-border/60 pt-1.5 text-[10px] text-muted">
|
||||
点击前往数据源配置
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,8 +335,8 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
|
||||
|
||||
export function Layout() {
|
||||
// ===== 共享 hooks (替代内联 useQuery) =====
|
||||
const { data: caps } = useCapabilities()
|
||||
const { data: settingsState } = useSettings()
|
||||
const { data: matrix } = useCapabilityMatrix()
|
||||
const { data: versionData } = useVersion()
|
||||
const { data: prefs } = usePreferences()
|
||||
// 数据源列表 (用于实时行情状态显示当前数据源名称)
|
||||
@@ -446,13 +506,6 @@ export function Layout() {
|
||||
? '关闭实时行情'
|
||||
: '开启实时行情'
|
||||
|
||||
// 当前主数据源 (用于侧边栏数据源状态卡)
|
||||
const activeProvider = prefs?.daily_data_provider || 'tickflow'
|
||||
const activeProviderName = activeProvider === 'tickflow'
|
||||
? 'TickFlow'
|
||||
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
|
||||
const isCustomActive = activeProvider !== 'tickflow'
|
||||
|
||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询)
|
||||
const alertsTotalQuery = useQuery({
|
||||
queryKey: ['alerts-total'],
|
||||
@@ -572,15 +625,10 @@ export function Layout() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 状态卡 — 收起时隐藏 */}
|
||||
{!navCollapsed && (
|
||||
<div className="mt-2.5 border-t border-border/60 pt-1">
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
providerName={activeProviderName}
|
||||
isTickflow={!isCustomActive}
|
||||
/>
|
||||
{/* 状态卡 — 收起时隐藏 */}
|
||||
{!navCollapsed && (
|
||||
<div className="mt-2.5 border-t border-border/60 pt-1">
|
||||
<DataSourceHealthBadge matrix={matrix} />
|
||||
<div className="mx-2 border-t border-border/45" aria-hidden="true" />
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { MissingCapChip } from '@/lib/capability-labels'
|
||||
|
||||
export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
|
||||
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
|
||||
// hasCap: 日K批量能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
|
||||
export function ExtendHistoryPanel({ hasCap, isRunning, earliestDate, onStart }: {
|
||||
hasCap: boolean
|
||||
isRunning: boolean
|
||||
earliestDate: string | null
|
||||
onStart: () => void
|
||||
@@ -14,7 +15,7 @@ export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
|
||||
const qc = useQueryClient()
|
||||
const [value, setValue] = useState(6)
|
||||
const [unit, setUnit] = useState<'month' | 'year'>('month')
|
||||
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
|
||||
const hasBatchCap = hasCap
|
||||
|
||||
const extend = useMutation({
|
||||
mutationFn: () => api.extendHistory(value, unit),
|
||||
|
||||
@@ -5,7 +5,8 @@ import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { MissingCapChip } from '@/lib/capability-labels'
|
||||
|
||||
export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; onJobStart?: (jobId: string) => void }) {
|
||||
// hasCap: 分钟K能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
|
||||
export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJobStart?: (jobId: string) => void }) {
|
||||
const qc = useQueryClient()
|
||||
const prefs = useQuery({
|
||||
queryKey: QK.preferences,
|
||||
@@ -17,7 +18,7 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
||||
})
|
||||
|
||||
const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch']
|
||||
const hasMinuteCap = hasCap
|
||||
const enabled = prefs.data?.minute_sync_enabled ?? false
|
||||
const days = prefs.data?.minute_sync_days ?? 5
|
||||
const segmentDays = prefs.data?.minute_sync_segment_days ?? 20
|
||||
|
||||
@@ -18,14 +18,15 @@ function daysAgo(n: number): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
export function RepairDailyPanel({ caps, isRunning, latestDate, onStart }: {
|
||||
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
|
||||
// hasCap: 日K批量能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
|
||||
export function RepairDailyPanel({ hasCap, isRunning, latestDate, onStart }: {
|
||||
hasCap: boolean
|
||||
isRunning: boolean
|
||||
latestDate: string | null
|
||||
onStart: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
|
||||
const hasBatchCap = hasCap
|
||||
|
||||
// 默认起始日期: 最新数据往前推 30 天 (兼顾补缺口 + 复核近期数据, 成本不高)
|
||||
const [startDate, setStartDate] = useState(daysAgo(30))
|
||||
|
||||
@@ -32,6 +32,10 @@ export function Pill({ label, value }: { label: string; value: number | string }
|
||||
)
|
||||
}
|
||||
|
||||
// 卡片能力徽章的输入: TickFlow 套餐限额对象, 或路由门控合并后的布尔可用性
|
||||
// (矩阵 usable 合并进 caps 时, 非对象真值表示「经其他数据源可用, 无套餐限额可显」)。
|
||||
export type CapLimitValue = { rpm: number | null; batch: number | null; subscribe: number | null } | boolean
|
||||
|
||||
function CapBadge({ hasCap, isLocal, missingCapName, capInfo, localSuffix, customProvider }: {
|
||||
hasCap: boolean
|
||||
isLocal: boolean
|
||||
@@ -101,7 +105,7 @@ export function StatCard({
|
||||
skipped?: boolean
|
||||
stagePct?: number
|
||||
tierKey?: string
|
||||
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
|
||||
capLimits?: Record<string, CapLimitValue>
|
||||
customProvider?: string | null
|
||||
onSettings?: () => void
|
||||
onShowFields?: (table?: string) => void
|
||||
@@ -123,8 +127,10 @@ export function StatCard({
|
||||
|
||||
const meta = tierKey ? CARD_META[tierKey] : undefined
|
||||
const isLocal = meta?.capKey === ''
|
||||
const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined
|
||||
const hasCap = isLocal || !!capInfo
|
||||
// 布尔值 (路由门控合并) 只表达可用性; 限额信息仅当值为套餐对象时展示
|
||||
const rawCap = meta?.capKey ? capLimits?.[meta.capKey] : undefined
|
||||
const capInfo = rawCap && typeof rawCap === 'object' ? rawCap : undefined
|
||||
const hasCap = isLocal || !!rawCap
|
||||
|
||||
// 渲染字段说明入口图标
|
||||
// - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后)
|
||||
|
||||
+48
-2
@@ -1378,7 +1378,51 @@ export interface PluginDataSourceItem {
|
||||
status: string // 可用性原因 (供 UI 显示)
|
||||
description: string
|
||||
install_hint: string // 未装依赖时显示的安装命令
|
||||
homepage?: string // 插件官网/申请地址 (manifest 可选声明)
|
||||
api_key_env?: string // 声明后设置页提供 Key 输入框 (先探后存)
|
||||
api_key_masked?: string // 当前生效 Key 的脱敏串 (secrets.json 优先, .env 兜底; 与 TickFlow Key 同一展示契约)
|
||||
}
|
||||
|
||||
/** 数据源路由偏好字段 (每个能力一个, 与后端能力注册表一一对应) */
|
||||
export type ProviderField =
|
||||
| 'daily_data_provider'
|
||||
| 'adj_factor_provider'
|
||||
| 'minute_data_provider'
|
||||
| 'depth5_data_provider'
|
||||
| 'realtime_data_provider'
|
||||
| 'financial_data_provider'
|
||||
|
||||
/** 能力路由矩阵中的一个候选源 (candidates 只含当前确实可提供该能力的源) */
|
||||
export interface CapabilityCandidate {
|
||||
name: string
|
||||
display: string
|
||||
kind: 'builtin' | 'plugin' | 'custom'
|
||||
available: boolean
|
||||
status: string
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
/** 一个能力 (标准化数据集) 的路由视图 */
|
||||
export interface CapabilityRoute {
|
||||
id: string
|
||||
label: string
|
||||
desc: string
|
||||
field: ProviderField
|
||||
default: string
|
||||
tf_tier: string // TickFlow 所需最低订阅档位
|
||||
tf_available: boolean // 当前 TickFlow 档位是否提供该能力
|
||||
usable: boolean // 生效源当前能否真正提供 (各页能力门控的统一判定)
|
||||
current: string // 原始偏好值
|
||||
current_display: string
|
||||
effective: string // 当前生效源 (独立路由, current 即生效)
|
||||
effective_display: string
|
||||
candidates: CapabilityCandidate[] // 当前可用候选 (按当前 TickFlow 档位过滤)
|
||||
pending: CapabilityCandidate[] // 声明了该能力但未就绪的源 (置灰提示)
|
||||
}
|
||||
|
||||
export interface CapabilityMatrix {
|
||||
tickflow_tier?: string // TickFlow 当前档位基础名 (none/free/...)
|
||||
capabilities: CapabilityRoute[]
|
||||
}
|
||||
|
||||
export interface DataSourceLoadError {
|
||||
@@ -1464,6 +1508,7 @@ export interface Preferences {
|
||||
daily_data_provider?: string
|
||||
adj_factor_provider?: string
|
||||
minute_data_provider?: string
|
||||
depth5_data_provider?: string
|
||||
realtime_data_provider?: string
|
||||
financial_data_provider?: string
|
||||
data_source_job_timeout_s: number
|
||||
@@ -1587,6 +1632,7 @@ export const api = {
|
||||
|
||||
preferences: () => request<Preferences>('/api/settings/preferences'),
|
||||
dataSources: () => request<DataSourcesResponse>('/api/settings/data-sources'),
|
||||
capabilityMatrix: () => request<CapabilityMatrix>('/api/settings/capability-matrix'),
|
||||
dataSource: (name: string) => request<CustomSourceConfig>(`/api/settings/data-sources/${encodeURIComponent(name)}`),
|
||||
saveDataSource: (config: CustomSourceConfig) =>
|
||||
request<DataSourcesResponse>('/api/settings/data-sources', {
|
||||
@@ -1632,8 +1678,8 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, dataset, symbols, config }),
|
||||
}),
|
||||
updateDataProviders: (cfg: Partial<Pick<Preferences, 'daily_data_provider' | 'adj_factor_provider' | 'minute_data_provider' | 'realtime_data_provider' | 'financial_data_provider'>>) =>
|
||||
request<Pick<Preferences, 'daily_data_provider' | 'adj_factor_provider' | 'minute_data_provider' | 'realtime_data_provider'>>(
|
||||
updateDataProviders: (cfg: Partial<Pick<Preferences, ProviderField>>) =>
|
||||
request<Pick<Preferences, ProviderField>>(
|
||||
'/api/settings/preferences/data-providers',
|
||||
{ method: 'PUT', body: JSON.stringify(cfg) },
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// capability 内部名 → 用户能理解的中文标签
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { CapabilityMatrix, CapabilityRoute } from './api'
|
||||
|
||||
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
||||
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
|
||||
@@ -58,6 +59,30 @@ export function MissingCapChip({ capKey, label, to = '/settings?tab=data-sources
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 能力路由门控 (各页统一判定) =====
|
||||
// usable = 生效源当前能否提供该能力 (区别于 TickFlow 套餐视角):
|
||||
// 路由到可用插件/自定义源时同样可用; 路由到 TickFlow 但档位不足时不可用。
|
||||
// 矩阵未加载时返回 undefined, 调用方回退 TickFlow 套餐视角, 避免首屏闪烁。
|
||||
// 数据来自 useCapabilityMatrix (设置页与其他页面共享同一缓存)。
|
||||
|
||||
export type RouteCapId = 'realtime' | 'daily' | 'minute' | 'adj_factor' | 'financial'
|
||||
|
||||
export function routeCap(matrix: CapabilityMatrix | undefined, id: RouteCapId): CapabilityRoute | undefined {
|
||||
return matrix?.capabilities.find(c => c.id === id)
|
||||
}
|
||||
|
||||
export function routeCapUsable(matrix: CapabilityMatrix | undefined, id: RouteCapId): boolean | undefined {
|
||||
return routeCap(matrix, id)?.usable
|
||||
}
|
||||
|
||||
/** 生效源非 TickFlow 且当前可用时返回其展示名 (卡片徽章显示实际数据源), 否则 null */
|
||||
export function routeProviderDisplay(matrix: CapabilityMatrix | undefined, id: RouteCapId): string | null {
|
||||
const cap = routeCap(matrix, id)
|
||||
return cap && cap.usable && cap.effective !== 'tickflow' ? cap.effective_display : null
|
||||
}
|
||||
|
||||
// ===== TickFlow 档位 (仅 TickFlow 专属界面) =====
|
||||
|
||||
// 套餐等级 —— 仅用于 TickFlow 专属界面 (Key 配置 / 端点测速 / 引导页 tickflow 分支)。
|
||||
// 通用功能门槛一律用能力键 (capName/needCapText/MissingCapChip), 不用档位词。
|
||||
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
|
||||
|
||||
@@ -15,6 +15,7 @@ export const QK = {
|
||||
version: ['version'] as const,
|
||||
preferences: ['preferences'] as const,
|
||||
dataSources: ['data-sources'] as const,
|
||||
capabilityMatrix: ['capability-matrix'] as const,
|
||||
quoteStatus: ['quote-status'] as const,
|
||||
quoteInterval: ['quote-interval'] as const,
|
||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||
|
||||
@@ -18,6 +18,19 @@ export function useCapabilities() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 能力路由矩阵 — 设置页与各页能力门控共用。
|
||||
|
||||
* 区别于 useCapabilities 的 TickFlow 套餐视角: 矩阵按「生效源当前能否提供」
|
||||
* 判定 (usable), 路由到可用插件时同样可用。路由偏好 / 档位探测 / 插件装卸
|
||||
* 变化时由设置页失效, 各页共享同一缓存自动刷新。
|
||||
*/
|
||||
export function useCapabilityMatrix() {
|
||||
return useQuery({
|
||||
queryKey: QK.capabilityMatrix,
|
||||
queryFn: api.capabilityMatrix,
|
||||
})
|
||||
}
|
||||
|
||||
/** 设置状态 — Layout / Data / Keys 共用 */
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
|
||||
+44
-44
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { EndpointTestDialog } from '@/components/EndpointTestDialog'
|
||||
import { api, type ExtDataConfig } from '@/lib/api'
|
||||
import {
|
||||
useCapabilities,
|
||||
useCapabilityMatrix,
|
||||
useSettings,
|
||||
usePreferences,
|
||||
useQuoteStatus,
|
||||
@@ -29,13 +30,13 @@ import {
|
||||
useDataStatus,
|
||||
} from '@/lib/useSharedQueries'
|
||||
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
|
||||
import { MissingCapChip } from '@/lib/capability-labels'
|
||||
import { MissingCapChip, routeCapUsable, routeProviderDisplay, type RouteCapId } from '@/lib/capability-labels'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
|
||||
|
||||
// 拆分出的子组件
|
||||
import { StatCard, type FieldTab } from '@/components/data/StatCard'
|
||||
import { StatCard, type FieldTab, type CapLimitValue } from '@/components/data/StatCard'
|
||||
import { ActiveJobCard } from '@/components/data/ActiveJobCard'
|
||||
import { SectionTitle, HistoryRow } from '@/components/data/SectionTitle'
|
||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||
@@ -180,25 +181,23 @@ export function Data() {
|
||||
? 'TickFlow'
|
||||
: (dataSources.data?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
|
||||
|
||||
// tierKey → 自定义数据集名映射 (用于数据画像 CapBadge 显示数据源名而非 TickFlow 档位)
|
||||
const TIERKEY_TO_DATASET: Record<string, string> = {
|
||||
daily: 'daily',
|
||||
adj_factor: 'adj_factor',
|
||||
etf: 'daily', // ETF 复用日K能力
|
||||
minute: 'minute',
|
||||
financials: 'financial',
|
||||
}
|
||||
// 当前 custom 源支持的数据集集合
|
||||
const activeCustomDatasets = activeProvider !== 'tickflow'
|
||||
? new Set(dataSources.data?.custom?.find(s => s.name === activeProvider)?.datasets || [])
|
||||
: new Set<string>()
|
||||
// 给定 tierKey, 返回 custom provider 显示名 (走 custom 时) 或 null (走 TickFlow)
|
||||
const getCustomProviderName = (tierKey: string): string | null => {
|
||||
if (activeProvider === 'tickflow') return null
|
||||
const ds = TIERKEY_TO_DATASET[tierKey]
|
||||
if (ds && activeCustomDatasets.has(ds)) return activeDataSourceName
|
||||
return null
|
||||
}
|
||||
// —— 能力路由门控 (全项目统一判定) ——
|
||||
// usable = 生效源当前能否提供该能力 (含插件/自定义源; TickFlow 档位不足则不可用),
|
||||
// 区别于 useCapabilities 的 TickFlow 套餐视角。矩阵未加载时回退套餐视角, 避免首屏闪烁。
|
||||
const matrix = useCapabilityMatrix()
|
||||
const tfCaps = caps.data?.capabilities
|
||||
const usableOr = (id: RouteCapId, tfHas: boolean) => routeCapUsable(matrix.data, id) ?? tfHas
|
||||
// 合并视角 caps: 套餐限额键位 + 路由可用性覆盖, 卡片徽章/显隐/设置弹窗共用
|
||||
const mergedCaps = useMemo(() => {
|
||||
const m: Record<string, CapLimitValue> = { ...(tfCaps ?? {}) }
|
||||
const merge = (tfKey: string, usable: boolean) => { m[tfKey] = usable ? (m[tfKey] ?? true) : false }
|
||||
merge('adj_factor', usableOr('adj_factor', !!tfCaps?.['adj_factor']))
|
||||
merge('kline.daily.batch', usableOr('daily', !!tfCaps?.['kline.daily.batch']))
|
||||
merge('kline.minute.batch', usableOr('minute', !!tfCaps?.['kline.minute.batch']))
|
||||
merge('financial', usableOr('financial', !!tfCaps?.['financial']))
|
||||
return m
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tfCaps, matrix.data])
|
||||
|
||||
const minuteAuto = prefs.data?.minute_sync_enabled ?? false
|
||||
const pipelineSched = prefs.data?.pipeline_schedule ?? { hour: 15, minute: 30 }
|
||||
@@ -241,9 +240,10 @@ export function Data() {
|
||||
const quoteStatus = useQuoteStatus()
|
||||
const toggleQuote = useToggleRealtimeQuotes()
|
||||
|
||||
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
|
||||
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
|
||||
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
|
||||
// 路由感知能力门控: 矩阵判定生效源可用性, 未加载回退套餐视角
|
||||
const hasAdjCap = usableOr('adj_factor', !!tfCaps?.['adj_factor'])
|
||||
const hasDailyBatchCap = usableOr('daily', !!tfCaps?.['kline.daily.batch'])
|
||||
const hasMinuteCap = usableOr('minute', !!tfCaps?.['kline.minute.batch'])
|
||||
const indexAuto = prefs.data?.pipeline_pull_index ?? true
|
||||
const etfAuto = prefs.data?.pipeline_pull_etf ?? false
|
||||
const pipelineSteps = [
|
||||
@@ -262,7 +262,7 @@ export function Data() {
|
||||
window.addEventListener('data-card-visible-change', handler)
|
||||
return () => window.removeEventListener('data-card-visible-change', handler)
|
||||
}, [])
|
||||
const cardVisible = getCardVisibility(caps.data?.capabilities)
|
||||
const cardVisible = getCardVisibility(mergedCaps)
|
||||
// 引用 cardVisibleTick 触发重渲染(避免 lint 警告)
|
||||
void cardVisibleTick
|
||||
|
||||
@@ -400,7 +400,7 @@ export function Data() {
|
||||
skipped={skippedCards.has('instruments')}
|
||||
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="instruments"
|
||||
capLimits={caps.data?.capabilities}
|
||||
capLimits={mergedCaps}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('instruments')}
|
||||
/>
|
||||
@@ -417,8 +417,8 @@ export function Data() {
|
||||
skipped={skippedCards.has('daily')}
|
||||
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
customProvider={getCustomProviderName('daily')}
|
||||
capLimits={mergedCaps}
|
||||
customProvider={routeProviderDisplay(matrix.data, 'daily')}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('daily')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
|
||||
@@ -437,8 +437,8 @@ export function Data() {
|
||||
skipped={skippedCards.has('adj_factor')}
|
||||
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="adj_factor"
|
||||
capLimits={caps.data?.capabilities}
|
||||
customProvider={getCustomProviderName('adj_factor')}
|
||||
capLimits={mergedCaps}
|
||||
customProvider={routeProviderDisplay(matrix.data, 'adj_factor')}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('adj_factor')}
|
||||
/>
|
||||
@@ -455,7 +455,7 @@ export function Data() {
|
||||
skipped={skippedCards.has('enriched')}
|
||||
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="enriched"
|
||||
capLimits={caps.data?.capabilities}
|
||||
capLimits={mergedCaps}
|
||||
auto
|
||||
subLabel={status.data?.indicators_ready === false ? '字段 · 指标计算中…' : '字段 · 指标 · 信号'}
|
||||
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
|
||||
@@ -476,7 +476,7 @@ export function Data() {
|
||||
skipped={skippedCards.has('index_daily')}
|
||||
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
capLimits={mergedCaps}
|
||||
auto={indexAuto}
|
||||
subLabel={indexOverviewLabel}
|
||||
fieldTabs={[
|
||||
@@ -497,8 +497,8 @@ export function Data() {
|
||||
stats={etfOverviewStats}
|
||||
loading={isLoading}
|
||||
tierKey="etf"
|
||||
capLimits={caps.data?.capabilities}
|
||||
customProvider={getCustomProviderName('etf')}
|
||||
capLimits={mergedCaps}
|
||||
customProvider={routeProviderDisplay(matrix.data, 'daily')}
|
||||
auto={etfAuto}
|
||||
subLabel="维表 · 日K · 指标"
|
||||
fieldTabs={[
|
||||
@@ -521,8 +521,8 @@ export function Data() {
|
||||
skipped={skippedCards.has('minute')}
|
||||
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="minute"
|
||||
capLimits={caps.data?.capabilities}
|
||||
customProvider={getCustomProviderName('minute')}
|
||||
capLimits={mergedCaps}
|
||||
customProvider={routeProviderDisplay(matrix.data, 'minute')}
|
||||
auto={minuteAuto}
|
||||
onShowFields={() => setSchemaTable('minute')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
|
||||
@@ -538,8 +538,8 @@ export function Data() {
|
||||
stats={s?.financials ? { rows: s.financials.rows } : null}
|
||||
loading={isLoading}
|
||||
tierKey="financials"
|
||||
capLimits={caps.data?.capabilities}
|
||||
customProvider={getCustomProviderName('financials')}
|
||||
capLimits={mergedCaps}
|
||||
customProvider={routeProviderDisplay(matrix.data, 'financial')}
|
||||
subLabel={`历史股本 · ${historicalShareRows.toLocaleString()} 条`}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'financials' ? null : 'financials') : undefined}
|
||||
settingsOpen={openSettings === 'financials'}
|
||||
@@ -558,7 +558,7 @@ export function Data() {
|
||||
skipped={skippedCards.has('regime')}
|
||||
stagePct={activeCard === 'regime' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="regime"
|
||||
capLimits={caps.data?.capabilities}
|
||||
capLimits={mergedCaps}
|
||||
auto={prefs.data?.pipeline_regime_enabled === true}
|
||||
subLabel="状态 · 综合分 · 指标"
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'regime' ? null : 'regime') : undefined}
|
||||
@@ -973,7 +973,7 @@ export function Data() {
|
||||
{openSettings === 'daily' && (
|
||||
<SettingsModal title="日 K · 向前扩展历史" onClose={() => setOpenSettings(null)}>
|
||||
<ExtendHistoryPanel
|
||||
caps={caps.data}
|
||||
hasCap={hasDailyBatchCap}
|
||||
isRunning={!!activeJobId}
|
||||
earliestDate={s?.daily?.earliest_date ?? null}
|
||||
onStart={() => setOpenSettings(null)}
|
||||
@@ -986,7 +986,7 @@ export function Data() {
|
||||
{showRepair && (
|
||||
<SettingsModal title="日 K · 修正 / 补数据" onClose={() => setShowRepair(false)}>
|
||||
<RepairDailyPanel
|
||||
caps={caps.data}
|
||||
hasCap={hasDailyBatchCap}
|
||||
isRunning={!!activeJobId}
|
||||
latestDate={s?.daily?.latest_date ?? null}
|
||||
onStart={() => setShowRepair(false)}
|
||||
@@ -1038,7 +1038,7 @@ export function Data() {
|
||||
<AnimatePresence>
|
||||
{openSettings === 'page-settings' && (
|
||||
<SettingsModal title="页面设置 · 数据画像卡片" onClose={() => setOpenSettings(null)}>
|
||||
<PageSettingsModal caps={caps.data?.capabilities} />
|
||||
<PageSettingsModal caps={mergedCaps} />
|
||||
</SettingsModal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -1147,7 +1147,7 @@ export function Data() {
|
||||
<AnimatePresence>
|
||||
{openSettings === 'minute' && (
|
||||
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
|
||||
<MinuteSyncConfig caps={caps.data} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
|
||||
<MinuteSyncConfig hasCap={hasMinuteCap} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
|
||||
</SettingsModal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { RefreshCw, Download, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink, ChartPie } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
import { useCapabilities, useCapabilityMatrix } from '@/lib/useSharedQueries'
|
||||
import { routeCapUsable } from '@/lib/capability-labels'
|
||||
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
||||
@@ -30,8 +32,12 @@ const TABLE_ICON: Record<string, typeof FileText> = {
|
||||
|
||||
export function Financials() {
|
||||
const { data: caps } = useCapabilities()
|
||||
const { data: matrix } = useCapabilityMatrix()
|
||||
const { data: status, isLoading } = useFinancialStatus()
|
||||
const hasFinancial = caps?.capabilities?.['financial'] != null || status?.available === true
|
||||
// 路由感知门控: 生效源当前能否提供财务数据 (含插件/自定义源);
|
||||
// 矩阵未加载时回退 TickFlow 套餐视角 + 后端可用状态
|
||||
const hasFinancial = routeCapUsable(matrix, 'financial')
|
||||
?? (caps?.capabilities?.['financial'] != null || status?.available === true)
|
||||
const syncMut = useFinancialSync()
|
||||
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
|
||||
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
|
||||
@@ -72,6 +78,12 @@ export function Financials() {
|
||||
<p className="mt-2 text-xs leading-relaxed text-secondary">
|
||||
当前数据源未提供财务数据。配置提供财务数据的数据源后,此页自动显示财务数据面板。
|
||||
</p>
|
||||
<Link
|
||||
to="/settings?tab=data-sources"
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-btn bg-accent/90 px-3.5 py-1.5 text-xs font-medium text-base hover:bg-accent transition-colors"
|
||||
>
|
||||
前往数据源配置
|
||||
</Link>
|
||||
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
|
||||
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||
@@ -274,7 +286,7 @@ export function Financials() {
|
||||
<div className="rounded-card border border-dashed border-border bg-surface px-6 py-14 text-center">
|
||||
<Database className="mx-auto h-8 w-8 text-muted" />
|
||||
<div className="mt-3 text-sm text-secondary">暂无财务数据</div>
|
||||
<div className="mt-1 text-xs text-muted">点击右上角"全部同步"从 TickFlow 拉取</div>
|
||||
<div className="mt-1 text-xs text-muted">点击右上角"全部同步"从当前数据源拉取</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
signal: '信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', sector: '板块监控',
|
||||
@@ -138,6 +138,10 @@ export function Monitor() {
|
||||
|
||||
// 全局 ext 字段配置 (监控中心个股通知带行业/概念标签)
|
||||
const { data: prefs } = usePreferences()
|
||||
// 实时行情可用性: mode=none 表示当前生效数据源完全无法提供实时行情
|
||||
// (TickFlow 无有效 Key, 或路由源未就绪) — 监控/预警收不到最新价, 顶部提示去数据源配置。
|
||||
const { data: quoteStatus } = useQuoteStatus()
|
||||
const realtimeUnavailable = quoteStatus?.mode === 'none'
|
||||
const monitorExtFields = prefs?.monitor_ext_fields ?? {
|
||||
concept: { field: 'ext_gn_ths.所属概念' },
|
||||
industry: { field: 'ext_hy_ths.所属同花顺行业' },
|
||||
@@ -185,6 +189,22 @@ export function Monitor() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader title="监控中心" subtitle="实时信号与规则管理" />
|
||||
{realtimeUnavailable && (
|
||||
<div className="px-5 pb-1">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-2.5 rounded-xl border border-warning/30 bg-warning/[0.06] px-4 py-2.5">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
|
||||
<span className="text-xs leading-relaxed text-secondary">
|
||||
实时行情当前不可用 — 监控与预警收不到最新价。可接入提供实时行情的数据源。
|
||||
</span>
|
||||
<Link
|
||||
to="/settings?tab=data-sources"
|
||||
className="ml-auto shrink-0 rounded-btn bg-warning/15 px-2.5 py-1 text-[11px] font-medium text-warning hover:bg-warning/25 transition-colors"
|
||||
>
|
||||
前往数据源配置
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 px-5 py-4">
|
||||
<div className="mx-auto flex h-full max-w-7xl flex-col gap-4 lg:flex-row">
|
||||
{/* 左栏: 触发记录 */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ import {
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Save,
|
||||
@@ -17,17 +15,50 @@ import {
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
||||
import { useSettings } from '@/lib/useSharedQueries'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { CAP_LABELS, tierTextStyle, tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
|
||||
import { tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
|
||||
|
||||
// ===== TickFlow Key 配置主体 (可嵌入 DataSources 的 TickFlow 详情区) =====
|
||||
// ===== TickFlow 详情内嵌区块 (组合进数据源页的单一详情卡, 不再各自成卡) =====
|
||||
// 档位变化会重塑能力矩阵候选 (按档位过滤) → Key/档位相关写操作统一连带失效。
|
||||
|
||||
export function TickFlowKeyConfig() {
|
||||
/** 区块小标题 (详情卡内部的分节, 区别于页面级 section) */
|
||||
function SectionHeading({ icon: Icon, title, badge, right }: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
badge?: string
|
||||
right?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-3.5 w-3.5 text-secondary shrink-0" />
|
||||
<h3 className="text-xs font-medium text-foreground">{title}</h3>
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted shrink-0">{badge}</span>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Key/档位/能力矩阵/侧栏状态的统一连带失效 (档位变化重塑矩阵候选) */
|
||||
export function useInvalidateTierRelated() {
|
||||
const qc = useQueryClient()
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilityMatrix })
|
||||
// 档位变化会改变实时行情模式(none/watchlist/full_market), 立即刷新侧边栏状态
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
}
|
||||
}
|
||||
|
||||
/** API Key 配置区块: 状态 + 输入 + 保存并检测 (先探后存) */
|
||||
export function TickFlowKeySection() {
|
||||
const settings = useSettings()
|
||||
const caps = useCapabilities()
|
||||
const invalidate = useInvalidateTierRelated()
|
||||
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [revealing, setRevealing] = useState(false)
|
||||
@@ -38,10 +69,7 @@ export function TickFlowKeyConfig() {
|
||||
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
|
||||
onSuccess: (data) => {
|
||||
setKeyInput('')
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
// 档位变化会改变实时行情模式(none/watchlist/full_market), 立即刷新侧边栏状态
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
invalidate()
|
||||
if (data.ok) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
@@ -52,266 +80,148 @@ export function TickFlowKeyConfig() {
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearTickflowKey(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
onSuccess: () => invalidate(),
|
||||
})
|
||||
|
||||
const redetect = useMutation({
|
||||
mutationFn: api.redetectCapabilities,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
mutationFn: () => api.redetectCapabilities(),
|
||||
onSuccess: () => invalidate(),
|
||||
})
|
||||
|
||||
const mode = settings.data?.mode
|
||||
const masked = settings.data?.tickflow_api_key_masked
|
||||
const capCount = caps.data ? Object.keys(caps.data.capabilities).length : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.3fr] gap-6 max-w-5xl">
|
||||
{/* ========== 左列: Key 配置 ========== */}
|
||||
<div className="space-y-6">
|
||||
<Card icon={Key} title="TickFlow API Key">
|
||||
<p className="text-sm text-secondary leading-relaxed mb-4">
|
||||
在{' '}
|
||||
<a
|
||||
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
|
||||
>
|
||||
tickflow.org
|
||||
<ExternalLink className="h-3 w-3 self-center" />
|
||||
</a>{' '}
|
||||
注册获取。API Key 存放为本地文件,不会上传任何第三方,请妥善保管。
|
||||
</p>
|
||||
<div>
|
||||
<SectionHeading icon={Key} title="TickFlow API Key" />
|
||||
|
||||
{/* 当前状态 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-widest text-muted">状态</div>
|
||||
<div className="mt-1 flex items-center gap-2 min-w-0">
|
||||
{mode === 'api_key' ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
|
||||
<span className="text-sm font-medium shrink-0">已配置</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : mode === 'free' ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
|
||||
<span className="text-sm font-medium shrink-0">免费 Key</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
|
||||
<span className="text-sm font-medium text-muted">未配置</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(mode === 'api_key' || mode === 'free') && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated text-secondary hover:text-danger text-xs transition-colors duration-150 ease-smooth disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed mb-4">
|
||||
在{' '}
|
||||
<a
|
||||
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
|
||||
>
|
||||
tickflow.org
|
||||
<ExternalLink className="h-3 w-3 self-center" />
|
||||
</a>{' '}
|
||||
注册获取。API Key 存放为本地文件,不会上传任何第三方,请妥善保管。
|
||||
</p>
|
||||
|
||||
{/* 输入 */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (keyInput.trim()) save.mutate()
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={revealing ? 'text' : 'password'}
|
||||
placeholder={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
|
||||
value={keyInput}
|
||||
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
|
||||
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealing((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors duration-150 ease-smooth"
|
||||
tabIndex={-1}
|
||||
aria-label={revealing ? '隐藏' : '显示'}
|
||||
>
|
||||
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={save.isPending || (!keyInput.trim() && !saved)}
|
||||
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? '保存中...' : saved ? '已保存' : '保存并检测'}
|
||||
</button>
|
||||
|
||||
{/* 检测中提示 —— 成功/失败后自动消失 */}
|
||||
{save.isPending && (
|
||||
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>
|
||||
验证通过前请不要离开当前页面 · 如遇网络问题请点击
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { save.reset(); redetect.mutate() }}
|
||||
disabled={redetect.isPending}
|
||||
className="font-semibold underline underline-offset-2 hover:text-warning/80 disabled:opacity-50"
|
||||
>
|
||||
{redetect.isPending ? '重新检测中…' : '重新检测'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{save.isError && (
|
||||
<div className="mt-3 text-xs text-danger">
|
||||
保存失败:{String((save.error as any).message)}
|
||||
</div>
|
||||
)}
|
||||
{/* 无效 key —— 先探后存:探测失败(key 无效/乱填)时不存储,提示用户 */}
|
||||
{save.data && !save.data.ok && (
|
||||
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
{save.data.reason === 'invalid'
|
||||
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
|
||||
: save.data.error ?? '保存失败'}
|
||||
</div>
|
||||
)}
|
||||
{save.data?.ok && (
|
||||
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
保存成功 — 档位 {save.data.tier_label}
|
||||
{save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ========== 右列: 档位 + 能力 ========== */}
|
||||
<div className="space-y-6">
|
||||
<Card
|
||||
icon={Activity}
|
||||
title="订阅档位"
|
||||
right={
|
||||
<button
|
||||
onClick={() => redetect.mutate()}
|
||||
disabled={redetect.isPending}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${redetect.isPending ? 'animate-spin' : ''}`} />
|
||||
重新检测
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{caps.data ? (
|
||||
{/* 当前状态 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-widest text-muted">状态</div>
|
||||
<div className="mt-1 flex items-center gap-2 min-w-0">
|
||||
{mode === 'api_key' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="font-mono text-3xl font-bold tracking-tight" style={tierTextStyle(caps.data.label)}>
|
||||
{caps.data.label}
|
||||
</div>
|
||||
<TierHelpPopover currentLabel={caps.data.label} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
根据 API Key 自动检测 · 拥有"代表性 capability"任一即认为该档
|
||||
</div>
|
||||
|
||||
{settings.data?.missing_caps && settings.data.missing_caps.length > 0 && (
|
||||
<div className="mt-3 rounded-btn border border-warning/40 bg-warning/5 px-3 py-2 text-xs">
|
||||
<div className="font-medium text-warning mb-1">
|
||||
本档应有但未探测到({settings.data.missing_caps.length} 项)
|
||||
</div>
|
||||
<div className="text-secondary space-y-0.5">
|
||||
{settings.data.missing_caps.map((c) => (
|
||||
<div key={c} className="font-mono">
|
||||
{CAP_LABELS[c]?.name ?? c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
|
||||
<span className="text-sm font-medium shrink-0">已配置</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : mode === 'free' ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
|
||||
<span className="text-sm font-medium shrink-0">免费 Key</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted">加载中…</div>
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
|
||||
<span className="text-sm font-medium text-muted">未配置</span>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card icon={CheckCircle2} title="可用功能" badge={`${capCount} 项`}>
|
||||
{caps.data && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="-mx-5 -mb-5"
|
||||
>
|
||||
<div className="border-t border-border">
|
||||
{Object.entries(caps.data.capabilities).map(([cap, lim]) => {
|
||||
const meta = CAP_LABELS[cap]
|
||||
return (
|
||||
<div
|
||||
key={cap}
|
||||
className="px-5 py-3 border-b border-border last:border-b-0 flex items-baseline justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground truncate">
|
||||
{meta?.name ?? cap}
|
||||
</div>
|
||||
{meta?.hint && (
|
||||
<div className="mt-0.5 text-[11px] text-muted truncate">
|
||||
{meta.hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 text-xs">
|
||||
<div className="font-mono text-foreground">
|
||||
{lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'}
|
||||
</div>
|
||||
{lim.batch && (
|
||||
<div className="font-mono text-muted">{lim.batch} 只/次</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{settings.data?.probe_log && settings.data.probe_log.length > 0 && (
|
||||
<details className="mt-4 -mx-5 -mb-5 border-t border-border">
|
||||
<summary className="cursor-pointer px-5 py-3 text-xs text-muted hover:text-secondary transition-colors duration-150 ease-smooth select-none">
|
||||
查看检测日志
|
||||
</summary>
|
||||
<div className="px-5 pb-4 font-mono text-[11px] space-y-0.5 text-secondary">
|
||||
{settings.data.probe_log.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{(mode === 'api_key' || mode === 'free') && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated text-secondary hover:text-danger text-xs transition-colors duration-150 ease-smooth disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入 + 保存: 左右一行 */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (keyInput.trim()) save.mutate()
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
type={revealing ? 'text' : 'password'}
|
||||
placeholder={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
|
||||
value={keyInput}
|
||||
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
|
||||
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealing((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors duration-150 ease-smooth"
|
||||
tabIndex={-1}
|
||||
aria-label={revealing ? '隐藏' : '显示'}
|
||||
>
|
||||
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={save.isPending || (!keyInput.trim() && !saved)}
|
||||
className="h-9 shrink-0 px-4 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? '保存中...' : saved ? '已保存' : '保存并检测'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 检测中提示 —— 成功/失败后自动消失 */}
|
||||
{save.isPending && (
|
||||
<div className="mt-2 flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>
|
||||
验证通过前请不要离开当前页面 · 如遇网络问题请点击
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { save.reset(); redetect.mutate() }}
|
||||
disabled={redetect.isPending}
|
||||
className="font-semibold underline underline-offset-2 hover:text-warning/80 disabled:opacity-50"
|
||||
>
|
||||
{redetect.isPending ? '重新检测中…' : '重新检测'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{save.isError && (
|
||||
<div className="mt-3 text-xs text-danger">
|
||||
保存失败:{String((save.error as any).message)}
|
||||
</div>
|
||||
)}
|
||||
{/* 无效 key —— 先探后存:探测失败(key 无效/乱填)时不存储,提示用户 */}
|
||||
{save.data && !save.data.ok && (
|
||||
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
{save.data.reason === 'invalid'
|
||||
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
|
||||
: save.data.error ?? '保存失败'}
|
||||
</div>
|
||||
)}
|
||||
{save.data?.ok && (
|
||||
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
保存成功 — 档位 {save.data.tier_label}
|
||||
{save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 确认清除 Key 弹窗 */}
|
||||
{confirmClear && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
@@ -342,15 +252,13 @@ export function TickFlowKeyConfig() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 通用卡片 =====
|
||||
|
||||
// ===== 档位说明弹窗 =====
|
||||
|
||||
function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
export function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const currentBase = tierBaseName(currentLabel)
|
||||
|
||||
@@ -412,32 +320,3 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface CardProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
badge?: string
|
||||
right?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
|
||||
return (
|
||||
<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">
|
||||
<Icon className="h-4 w-4 text-secondary" />
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user