feat(ui): 能力徽章与数据源页重构——路由门控、中立文案、详情卡重排

- 侧栏数据源徽章: 六能力方格(按注册序) + 悬浮路由卡(逐格同色对应,
  fixed 定位逃逸侧栏裁剪, 渲染后按实际高度钳制视口防遮挡)
- 各页能力门控切换为矩阵 usable 视角: Data/Financials/Monitor 缺能力时
  提示并引导数据源配置; 门控判定不再绑定 TickFlow 套餐
- 设置页数据源区: 能力路由卡(点标签切换, 乐观更新)、TickFlow 详情改为
  左 API Key 右能力档位表、检测档位收进头部徽章集群(+/重检测)、
  可用功能收进悬停图标、订阅档位面板移除
- 插件详情与 TickFlow 同构: 能力适配表(服务中/已适配/未就绪/—) +
  Key 区申请话术嵌官网链接(manifest homepage)
- 能力层文案中立化: 通用界面不出现档位/订阅词汇, 能力不可用警示按
  usable 判定(TickFlow 档位不足与源未就绪同待遇)
- API Key 输入框与保存按钮改为一行左右布局
This commit is contained in:
shy3130
2026-08-30 19:05:22 +08:00
parent b4aa5918fc
commit 6fb46b100a
14 changed files with 1239 additions and 1005 deletions
+136 -88
View File
@@ -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))
+9 -3
View File
@@ -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
View File
@@ -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) },
),
+25
View File
@@ -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")。
+1
View File
@@ -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,
+13
View File
@@ -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
View File
@@ -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>
+15 -3
View File
@@ -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>
) : (
<>
+21 -1
View File
@@ -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
+165 -286
View File
@@ -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>
)
}