Files
tick-stock-panel/frontend/src/pages/settings/DataSources.tsx
T

628 lines
29 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, Clock3, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react'
import { api, type DataSourceItem, type PluginDataSourceItem, type Preferences } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
import { toast } from '@/components/Toast'
import { DataSourceEditor } from './DataSourceEditor'
import { TickFlowKeyConfig } from './Keys'
const DATASET_LABEL: Record<string, string> = {
daily: '日K',
adj_factor: '除权',
realtime: '实时',
minute: '分钟',
}
type TimeoutUnit = 'second' | 'minute' | 'hour'
const TIMEOUT_UNIT_SECONDS: Record<TimeoutUnit, number> = {
second: 1,
minute: 60,
hour: 3600,
}
function preferredTimeoutUnit(seconds: number): TimeoutUnit {
if (seconds >= 3600 && seconds % 1800 === 0) return 'hour'
if (seconds % 60 === 0) return 'minute'
return 'second'
}
function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string {
if (!Number.isFinite(seconds)) return ''
const value = seconds / TIMEOUT_UNIT_SECONDS[unit]
return String(Number(value.toFixed(4)))
}
export function SettingsDataSourcesPanel() {
const qc = useQueryClient()
const prefs = usePreferences()
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources })
const [selected, setSelected] = useState<string>('tickflow') // 当前在右侧编辑的源 name
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null)
const [regularUnitOverride, setRegularUnitOverride] = useState<TimeoutUnit | null>(null)
const [longUnitOverride, setLongUnitOverride] = useState<TimeoutUnit | null>(null)
const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200
const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800
const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout)
const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout)
const regularTimeoutInput = timeoutDraft?.regular
?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit)
const longTimeoutInput = timeoutDraft?.long
?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit)
const regularInputNumber = Number(regularTimeoutInput)
const longInputNumber = Number(longTimeoutInput)
const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit])
const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit])
const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0
&& Number.isFinite(longInputNumber) && longInputNumber > 0
&& regularTimeout >= 60 && longTimeout >= 60
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|| longTimeout !== currentLongTimeout
const saveJobTimeouts = useMutation({
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
onSuccess: (saved) => {
qc.setQueryData<Preferences>(QK.preferences, current => (
current ? { ...current, ...saved } : current
))
setTimeoutDraft(null)
toast('任务超时配置已保存', 'success')
},
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
})
const reload = useMutation({
mutationFn: api.reloadDataSources,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataSources })
toast('配置已重新加载', 'success')
},
})
const remove = useMutation({
mutationFn: (name: string) => api.deleteDataSource(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.preferences })
setSelected('tickflow')
setConfirmDelete(null)
toast('数据源已删除', 'success')
},
})
const switchProvider = useMutation({
mutationFn: (name: string) => {
// tickflow: 5 个数据集全量重置为 tickflow
if (name === 'tickflow') {
return api.updateDataProviders({
daily_data_provider: 'tickflow',
adj_factor_provider: 'same_as_daily',
realtime_data_provider: 'tickflow',
minute_data_provider: 'tickflow',
financial_data_provider: 'tickflow',
})
}
// 非 tickflow: 按源声明的 datasets 动态切换。支持的数据集切到该源,
// 不支持的保持 tickflow, 使 preferences 与实际取数路由一致
// (避免 UI 显示某源、后台却走 tickflow 的假象)。
const supported = new Set(
allItems.find(s => s.name === name)?.datasets ?? []
)
const pick = (dataset: string) => (supported.has(dataset) ? name : 'tickflow')
return api.updateDataProviders({
daily_data_provider: pick('daily'),
adj_factor_provider: 'same_as_daily', // 除权始终跟随日K
realtime_data_provider: pick('realtime'),
minute_data_provider: pick('minute'),
financial_data_provider: pick('financial'),
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
toast('数据源已切换', 'success')
},
})
const editExisting = useMutation({
mutationFn: (name: string) => api.dataSource(name),
onSuccess: (_data, name) => setSelected(name),
})
const installMut = useMutation({
mutationFn: (name: string) => api.installPlugin(name),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.dataSources })
if (data.install_ok) {
toast('插件依赖安装成功', 'success')
} else {
toast(data.install_message || '安装失败', 'error')
}
},
onError: (e: Error) => toast(`安装失败: ${e.message}`, 'error'),
})
const uninstallMut = useMutation({
mutationFn: (name: string) => api.uninstallPlugin(name),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.preferences })
if (data.uninstall_ok) {
toast(data.uninstall_message || '已卸载', 'success')
} else {
toast(data.uninstall_message || '卸载失败', 'error')
}
},
onError: (e: Error) => toast(`卸载失败: ${e.message}`, 'error'),
})
const builtin: DataSourceItem[] = sources.data?.builtin ?? []
const pluginList: PluginDataSourceItem[] = sources.data?.plugins ?? []
const customList: DataSourceItem[] = sources.data?.custom ?? []
const errors = sources.data?.errors ?? []
const activeName = prefs.data?.daily_data_provider || 'tickflow'
// 插件 name → 状态 (供卡片渲染时判断 available/installing 等)
const pluginMap = new Map(pluginList.map(p => [p.name, p]))
const pluginNames = new Set(pluginList.map(p => p.name))
// 顶部数据源选择列表 (内置 + 所有插件 + 自定义 + 新增)
const pluginItems: DataSourceItem[] = pluginList.map(p => ({
name: p.name, display_name: p.display_name, datasets: p.datasets,
}))
const allItems = [
...builtin,
...pluginItems,
...customList,
]
const selectedCustom = customList.find(s => s.name === selected)
return (
<div className="space-y-5 max-w-5xl">
{/* ===== 顶部: 当前数据源 + 数据源选择 (一个大卡片) ===== */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<Database className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">数据源</h2>
<span
className="text-[10px] text-muted/40 font-mono truncate hidden lg:inline max-w-[480px]"
title={sources.data?.config_dir}
>
{sources.data?.config_dir}
</span>
</div>
<button
onClick={() => reload.mutate()}
disabled={reload.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs text-muted hover:text-foreground hover:bg-elevated transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${reload.isPending ? 'animate-spin' : ''}`} />
重新加载
</button>
</div>
{/* 当前数据源状态 */}
<div className="flex items-center gap-2 mb-4 px-3 py-2.5 rounded-lg bg-elevated/30">
<span className="text-[10px] uppercase tracking-widest text-muted">当前</span>
<span className="h-2 w-2 rounded-full bg-accent animate-pulse" />
<span className="text-sm font-medium text-foreground">
{activeName === 'tickflow' ? 'TickFlow' : customList.find(s => s.name === activeName)?.display_name || activeName}
</span>
</div>
{/* 数据源选择 - 横向卡片列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{allItems.map(item => {
const isActive = activeName === item.name
const isSelected = selected === item.name
const plugin = pluginMap.get(item.name)
const pluginUnavailable = plugin && !plugin.available
const installing = installMut.isPending && installMut.variables === item.name
const uninstalling = uninstallMut.isPending && uninstallMut.variables === item.name
return (
<div
key={item.name}
onClick={() => {
if (pluginUnavailable) return // 未安装的插件不可选中
setSelected(item.name)
// 只有用户自定义源 (YAML) 才进编辑器; tickflow 和插件不可编辑
if (customList.some(c => c.name === item.name)) {
editExisting.mutate(item.name)
}
}}
className={`relative text-left rounded-lg border px-3.5 py-3 transition-all ${
pluginUnavailable
? 'border-border/40 bg-elevated/10 opacity-70'
: isSelected
? 'border-accent/50 bg-accent/5 ring-1 ring-accent/20 cursor-pointer'
: 'border-border/60 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${
pluginUnavailable ? 'bg-muted/30' : isActive ? 'bg-accent' : 'bg-transparent border border-muted/40'
}`} />
<span className={`text-sm truncate flex-1 ${isActive ? 'font-medium text-foreground' : 'text-secondary'}`}>
{item.display_name}
</span>
{item.name === 'tickflow' && (
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">内置</span>
)}
{pluginNames.has(item.name) && (
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">插件</span>
)}
{/* 右侧操作区: 插件未安装→安装按钮; 已激活→使用中; 否则→使用/卸载 */}
{pluginUnavailable ? (
installing ? (
<span className="inline-flex items-center gap-1 text-[9px] text-accent shrink-0">
<RefreshCw className="h-2.5 w-2.5 animate-spin" /> 安装中...
</span>
) : (
<button
onClick={(e) => { e.stopPropagation(); installMut.mutate(item.name) }}
disabled={installMut.isPending}
className="shrink-0 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
<Zap className="h-2.5 w-2.5" /> 安装
</button>
)
) : isActive ? (
<span className="inline-flex items-center gap-0.5 text-[9px] text-accent shrink-0">
<Check className="h-2.5 w-2.5" /> 使用中
</span>
) : plugin ? (
/* 已安装插件: 使用 + 卸载 */
<div className="flex items-center gap-1 shrink-0">
<button
onClick={(e) => { e.stopPropagation(); switchProvider.mutate(item.name) }}
disabled={switchProvider.isPending}
className="rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
使用
</button>
{uninstalling ? (
<RefreshCw className="h-2.5 w-2.5 animate-spin text-muted" />
) : (
<button
onClick={(e) => { e.stopPropagation(); uninstallMut.mutate(item.name) }}
disabled={uninstallMut.isPending}
className="text-[10px] text-muted/50 hover:text-danger transition-colors disabled:opacity-40"
title="卸载依赖"
>
卸载
</button>
)}
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); switchProvider.mutate(item.name) }}
disabled={switchProvider.isPending}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
使用
</button>
)}
</div>
{item.name !== 'tickflow' && item.datasets.length > 0 && (
<div className="flex flex-wrap gap-1 ml-3.5">
{item.datasets.map(ds => (
<span key={ds} className="text-[9px] text-muted/60 bg-elevated/60 px-1 py-0.5 rounded">
{DATASET_LABEL[ds] || ds}
</span>
))}
</div>
)}
{item.name === 'tickflow' && (
<div className="text-[10px] text-muted/60 ml-3.5">K · 除权 · 实时 · 分钟K</div>
)}
{/* 未安装插件显示安装命令提示 */}
{pluginUnavailable && plugin?.install_hint && (
<div className="ml-3.5 mt-1 text-[10px] text-muted/40 font-mono truncate">{plugin.install_hint}</div>
)}
</div>
)
})}
{/* 新增数据源卡片 */}
<button
onClick={() => setSelected('__new__')}
className={`rounded-lg border border-dashed px-3.5 py-3 transition-all flex items-center justify-center gap-1.5 text-sm ${
selected === '__new__'
? 'border-accent/50 bg-accent/5 text-accent'
: 'border-border/50 text-muted hover:text-foreground hover:border-border hover:bg-elevated/30'
}`}
>
<Plus className="h-3.5 w-3.5" />
新增数据源
</button>
</div>
{/* 错误提示 */}
{errors.length > 0 && (
<div className="mt-3 flex items-start gap-1.5 px-3 py-2 rounded-lg bg-danger/5 border border-danger/20">
<FileWarning className="h-3.5 w-3.5 text-danger shrink-0 mt-0.5" />
<div className="text-[11px] text-danger/80 leading-relaxed space-y-0.5">
{errors.map((err, idx) => (
<div key={idx}>
<span className="font-mono">{err.name || err.path}</span>: {err.errors.join('; ')}
</div>
))}
</div>
</div>
)}
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted/50">
<span>单击编辑</span>
<span className="text-muted/30">·</span>
<span>点「使用」切换为当前数据源</span>
<span className="text-muted/30">·</span>
<span>未启用的数据集自动回退 TickFlow</span>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-start gap-2.5">
<Clock3 className="h-4 w-4 text-secondary mt-0.5" />
<div>
<h2 className="text-sm font-medium text-foreground">数据任务超时</h2>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。
</p>
</div>
</div>
<button
onClick={() => saveJobTimeouts.mutate()}
disabled={!timeoutValuesValid || !timeoutValuesChanged || saveJobTimeouts.isPending}
className="shrink-0 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors"
>
{saveJobTimeouts.isPending ? '保存中...' : '保存'}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
<span className="block text-xs font-medium text-foreground mb-1">普通任务超时</span>
<span className="block text-[10px] text-muted mb-2"> K 管道、扩展、修正与重算任务</span>
<div className="flex items-center gap-2">
<input
type="number"
min={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 1 / 60}
step={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 0.5}
value={regularTimeoutInput}
onChange={e => setTimeoutDraft({ regular: e.target.value, long: longTimeoutInput })}
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
/>
<select
value={regularTimeoutUnit}
onChange={e => {
const nextUnit = e.target.value as TimeoutUnit
setTimeoutDraft({
regular: formatTimeoutValue(regularTimeout, nextUnit),
long: longTimeoutInput,
})
setRegularUnitOverride(nextUnit)
}}
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
>
<option value="second"></option>
<option value="minute">分钟</option>
<option value="hour">小时</option>
</select>
</div>
<span className="block text-[10px] text-muted/60 mt-1.5">默认 20 分钟,最小 1 分钟</span>
</label>
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
<span className="block text-xs font-medium text-foreground mb-1">长任务超时</span>
<span className="block text-[10px] text-muted mb-2">分钟 K 全市场同步任务</span>
<div className="flex items-center gap-2">
<input
type="number"
min={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 1 / 60}
step={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 0.5}
value={longTimeoutInput}
onChange={e => setTimeoutDraft({ regular: regularTimeoutInput, long: e.target.value })}
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
/>
<select
value={longTimeoutUnit}
onChange={e => {
const nextUnit = e.target.value as TimeoutUnit
setTimeoutDraft({
regular: regularTimeoutInput,
long: formatTimeoutValue(longTimeout, nextUnit),
})
setLongUnitOverride(nextUnit)
}}
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
>
<option value="second"></option>
<option value="minute">分钟</option>
<option value="hour">小时</option>
</select>
</div>
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟,最小 1 分钟</span>
</label>
</div>
</section>
{/* ===== 下方: 编辑区 ===== */}
<AnimatePresence mode="wait">
<motion.div
key={selected}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
>
{selected === 'tickflow' ? (
<TickFlowDetail
active={activeName === 'tickflow'}
onSwitch={() => switchProvider.mutate('tickflow')}
switching={switchProvider.isPending}
/>
) : selected === '__new__' || customList.some(c => c.name === selected) ? (
<DataSourceEditor
key={selected}
initial={null}
existingName={selected === '__new__' ? undefined : selected}
onCancel={() => setSelected('tickflow')}
onSaved={() => {
qc.invalidateQueries({ queryKey: QK.dataSources })
// 强制清除该源的详情缓存, 下次编辑重新拉取最新配置
if (selected !== '__new__') {
qc.removeQueries({ queryKey: ['data-source-detail', selected] })
}
if (selected === '__new__') setSelected(activeName === 'tickflow' ? 'tickflow' : activeName)
}}
activeName={activeName}
onActivate={(name) => switchProvider.mutate(name)}
onDelete={selected !== '__new__' && selectedCustom ? () => setConfirmDelete(selected) : undefined}
/>
) : pluginList.find(x => x.name === selected) ? (
/* 选中插件: 显示只读详情, 不进编辑器 */
<PluginDetail
plugin={pluginList.find(x => x.name === selected)!}
isActive={activeName === selected}
onSwitch={() => switchProvider.mutate(selected)}
switching={switchProvider.isPending}
/>
) : null}
</motion.div>
</AnimatePresence>
{/* 删除确认弹窗 */}
{confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setConfirmDelete(null)}
/>
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2">删除数据源</h3>
<p className="text-xs text-secondary mb-5">
确认删除「{customList.find(s => s.name === confirmDelete)?.display_name || confirmDelete}? 该数据源的配置文件将被移除,此操作不可撤销。
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmDelete(null)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
>
取消
</button>
<button
onClick={() => remove.mutate(confirmDelete)}
disabled={remove.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
>
{remove.isPending ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function PluginDetail({ plugin, isActive, onSwitch, switching }: {
plugin: PluginDataSourceItem
isActive: boolean
onSwitch: () => void
switching: boolean
}) {
return (
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-start gap-4 mb-5">
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
<Zap className="h-5 w-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-foreground">{plugin.display_name}</h3>
<span className="text-[10px] text-muted/50 uppercase tracking-wider">插件 · {plugin.runtime}</span>
</div>
{plugin.description && <p className="text-xs text-secondary leading-relaxed">{plugin.description}</p>}
</div>
</div>
<div className="flex items-center gap-3">
{isActive ? (
<span className="inline-flex items-center gap-1.5 text-xs text-accent">
<Check className="h-3.5 w-3.5" /> 当前使用中
</span>
) : (
<button
onClick={onSwitch}
disabled={switching}
className="px-3 py-1.5 rounded-btn bg-accent/10 text-accent hover:bg-accent/20 text-xs font-medium transition-colors disabled:opacity-50"
>
切换为当前数据源
</button>
)}
</div>
</section>
)
}
function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSwitch: () => void; switching: boolean }) {
return (
<div className="space-y-5">
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-start gap-4 mb-5">
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
<Database className="h-5 w-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-base font-semibold text-foreground">TickFlow</h2>
<span className="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">内置默认</span>
{active && (
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-1.5 py-0.5 rounded">
<Check className="h-2.5 w-2.5" /> 当前使用
</span>
)}
</div>
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
项目默认数据源。日K、除权因子、实时行情、分钟K均由 TickFlow 提供,无需额外配置。
</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mb-5">
{[
{ label: '日K', desc: '历史 + 实时覆写' },
{ label: '除权因子', desc: 'Starter+ 能力' },
{ label: '实时行情', desc: '全市场快照' },
{ label: '分钟K', desc: 'Pro+ 能力' },
].map(f => (
<div key={f.label} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5">
<div className="text-xs font-medium text-foreground">{f.label}</div>
<div className="text-[10px] text-muted mt-0.5">{f.desc}</div>
</div>
))}
</div>
{!active && (
<button
onClick={onSwitch}
disabled={switching}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
<Zap className="h-3.5 w-3.5" />
切换为当前数据源
</button>
)}
</section>
{/* TickFlow API Key 配置 + 订阅档位 + 可用功能 (原 account tab 内容) */}
<TickFlowKeyConfig />
</div>
)
}